Skip to content

Complete Notes API support - #76

Open
i2h3 wants to merge 16 commits into
mainfrom
notes-api
Open

i2h3 wants to merge 16 commits into
mainfrom
notes-api

Conversation

@i2h3

@i2h3 i2h3 commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Completes Rainmaker's support of the Nextcloud notes app for 5.0.0. The new calls are listing incremental, conditional, chunked and summary-only; reading one note; creating, updating with If-Match conflict detection, and deleting; changing the settings; and attachments. Each one is a standalone, cancellable request, so Cirruscope can build Shortcuts actions on them directly. Also included:

  • pure helpers that mirror how the notes and Text apps derive titles and write attachment references
  • currentUser()
  • typed login polling
  • a polling fallback for server events
  • two DocC articles and the CLI coverage that goes with all of this

Breaking changes

  • Minimum notes API version is now 1.4 (notes app 4.12.3+).
  • RainmakerError gained appUnavailable(app:), insufficientStorage, locked, methodNotAllowed, noteConflict(current:) and readOnly.
  • A missing notes app is reported as appUnavailable(app: "notes") instead of notFound.
  • login() reports typed errors instead of a raw DecodingError.
  • The CLI's notes is now a group; notes --changed-since keeps working.

Fixtures

  • Recorded on 31.0.14 and 32.0.11 (Notes 5.0.2) and on 33.0.5 and 34.0.0 (Notes 6.1.0): NotesTests and the new NoteMutationTests, NoteAttachmentTests and CurrentUserTests. CapabilitiesTests/fetchAuthenticated was re-recorded on 33/34 only, for Notes 6.1.0.
  • A full re-recording was run as a check. Only meaningful changes were kept; the rest was churn.
  • Login flow tokens and placeholder app passwords are now redacted in the fixtures.

Cirruscope migration

  • Raise the rainmaker requirement to 5.0.0.
  • ServerConnection+Notes.swift: catch RainmakerError.appUnavailable(app: "notes") instead of .notFound. This still compiles unchanged but would silently stop clearing stored notes when the app is missing.
  • LoginSession.swift: replace try? poll(_:token:) with poll(_:). It returns nil while pending, so real failures surface instead of spinning until the timeout.
  • Optional:
    • noteSummaries with lastModified/entityTag removes the download of note bodies.
    • currentUser() supplies the user ID for avatars.
Draft release notes

Rainmaker 5.0.0 completes the support of the Nextcloud notes app: notes can now be synchronized incrementally, conditionally and in chunks, listed as summaries without their text, retrieved one at a time, created, changed with conflict detection and deleted, and files can be attached to them. Every one of these calls is a standalone, cancellable request, so they serve a client keeping its own copy of the notes as well as a single Shortcuts action. The release also adds a lookup of the authenticated account, a typed way to poll a login flow, and a choice of transport for server events which falls back to polling when the WebSocket cannot be reached.

Verified against Nextcloud 31.0.14, 32.0.11, 33.0.5 and 34.0.0 with Notes 5.0.2 and 6.1.0. Platform requirements are unchanged.

Breaking changes

  • The notes features require the notes API in version 1.4, which the notes app serves since release 4.12.3. Notes.minimumAPIVersion is "1.4" now, and an app serving only 1.3 is refused with unsupportedAPIVersion(app:required:advertised:). Such an app still carries out a creation, change or deletion before its answer reveals its version, so check Notes.isSupported before createNote, updateNote, deleteNote or updateNotesSettings where that matters. A success response without the notes app's version header, such as the login page of an authenticating proxy, is reported as responseDecodingFailed(reason:) rather than as an outdated app. Adding or deleting an attachment on such an app throws methodNotAllowed instead, because those routes do not exist there.
  • RainmakerError gained six cases: appUnavailable(app:), insufficientStorage, locked, methodNotAllowed, noteConflict(current:) and readOnly. Exhaustive switch statements need to handle them.
  • An absent notes app is reported as appUnavailable(app: "notes") by notes(), notes(changedSince:) and notesSettings(), where it used to be notFound. notFound is now reserved for a note or an attachment which does not exist, so a client keeping its own copy no longer mistakes a missing app for deleted notes. Retrieving an attachment is the exception: the notes app answers it with a bare 404 for every failure, so attachment(at:ofNote:) and downloadAttachment(at:ofNote:to:force:) report an absent app as notFound as well; check the Notes capability to tell the cases apart.
  • login() checks the status of the response. A 404 throws notFound, any other status than 200 throws unexpectedStatus(code:), and a body which is not a login flow throws responseDecodingFailed(reason:), where it used to throw a raw DecodingError or decode whatever a failed response carried.
  • CLI: notes is a group of subcommands now (list, the default, get, create, update, delete and attachment). notes --changed-since keeps working, but scripts which parse the help text see a different one. The JSON output of notes and of the notes settings carries additional keys.

Added

  • Incremental synchronization. NoteChanges reports the server's own moment to continue from as lastModified and the response's entityTag, and notes(changedSince:ifChangedFrom:) returns nil when the server answers 304 Not Modified.
  • Chunked listing. notes(changedSince:chunkSize:continuingAfter:) retrieves one chunk of a pass, notes(changedSince:chunkSize:ifChangedFrom:) its first chunk conditionally, and noteChunks(changedSince:chunkSize:) streams the chunks of one pass, requesting each one only when the consumer asks for it. NoteChanges gained chunkCursor, pendingCount and isComplete.
  • Summaries. noteSummaries(changedSince:), noteSummaries(changedSince:ifChangedFrom:), noteSummaries(changedSince:chunkSize:continuingAfter:), noteSummaries(changedSince:chunkSize:ifChangedFrom:) and noteSummaryChunks(changedSince:chunkSize:) list notes without their text as NoteSummary values in NoteSummaryChanges.
  • Single notes. note(_:) and note(_:ifChangedFrom:).
  • Writing notes. createNote(title:category:content:modification:isFavorite:), updateNote(_:title:category:content:modification:isFavorite:ifMatching:), which sends If-Match and reports a note changed elsewhere as noteConflict(current:) carrying its current state, and deleteNote(_:).
  • Notes settings. updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:), and NotesSettings gained noteMode, showsHiddenFiles and loadsRecentNoteOnStartUp with the new NoteMode.
  • Attachments. attachment(at:ofNote:) into memory, downloadAttachment(at:ofNote:to:force:) into a file, which never replaces a directory even when forced, addAttachment(_:toNote:fileName:) for a local file or for bytes, and deleteAttachment(at:ofNote:), with the NoteAttachment and NoteAttachmentFile results. Uploads stream through a staged multipart body, so neither the file nor the body is held in memory, and cancelling the task stops staging as well as the upload.
  • Note content helpers. NoteTitle, NoteCategory, NoteAttachmentReference and NoteAttachmentPath predict the title and category the server settles on, derive a title from content as the web interface does, and build and read attachment references exactly as the Nextcloud Text app writes them, without a request.
  • Note details. Note gained path, isShared and shareTypes with the new ShareType, and the Notes capability gained isAppVersion(atLeast:), attachmentFoldersAppVersion, storesAttachmentsPerNote and supportsAttachmentDeletion for behaviours tied to a release of the app.
  • Current user. currentUser() returns the identifier and the display name of the authenticated account as a User, which is what userAvatar(_:size:darkTheme:) expects rather than the login name.
  • Login polling. poll(_:) takes the LoginFlow and returns nil while the flow is pending, throwing only for real failures.
  • Event transport. ServerEventTransport and ServerEventOptions.transport choose between the automatic transport and polling only.
  • Public initializers for Note, NoteSummary, NoteChanges, NoteSummaryChanges, NotesSettings, NoteAttachment, NoteAttachmentFile, LoginFlow, LoginResult and User, so that tests and previews can fake them. LoginFlow, LoginResult, NoteChanges and NotesSettings conform to Hashable.
  • DocC articles "Synchronizing Notes" and "Using Notes From App Intents".
  • CLI: notes list with --chunk-size, --cursor, --if-none-match and --summaries, whose plain output ends with #cursor, #etag and #last-modified lines, notes get, notes create, notes update, notes delete, notes attachment get|add|delete, notes-settings options to change the settings, current-user, and watch --transport.

Changed

  • Requests of the notes app ignore the local URL cache, so a conditional request sees the server's real answer. A session shared by several accounts should still be configured without a URLCache, as the documentation of Server.init(address:password:user:session:webSocket:userAgent:) shows, because bypassing the cache does not keep a session from storing responses in it.
  • On watchOS, events(_:) polls, because watchOS does not offer the low-level networking a WebSocket needs outside of a few kinds of apps, and therefore no longer ends the stream when the server later rejects the credentials; notice that from the requests made in response to the hints. The same holds for the new .polling transport.
  • The automatic event transport polls for the rediscovery interval after three consecutive WebSocket connections which ended before authenticating, and then looks at the capabilities again. An authentication rejection and every fallback to polling reset that count.
  • The encoded JSON of Note, NoteChanges and NotesSettings gained keys for the new properties, with absent values written as null.
  • The CLI's poll command sleeps and retries while the flow is pending and ends with the error of a real failure instead of swallowing it.

Fixed

  • makeAppRequest(for:method:queryItems:) and makeOCSRequest(for:method:queryItems:) percent-encode +, & and = in query names and values. Before, the server read a + as a space.
  • The automatic event transport no longer retries an unreachable notify_push endpoint forever without delivering events, for example behind a proxy which does not forward WebSocket upgrades.
  • A poll window with nothing to poll waits out the window instead of returning at once, so the event loops cannot spin.

Deprecated

  • poll(_:token:) in favour of poll(_:). Its behaviour is unchanged.

Migration

  1. Add arms for appUnavailable, insufficientStorage, locked, methodNotAllowed, noteConflict and readOnly wherever RainmakerError is switched over exhaustively.
  2. Where a missing notes app was detected by catching notFound around notes(), notes(changedSince:) or notesSettings(), catch appUnavailable(app:) instead.
  3. Pass the lastModified of the previous NoteChanges as changedSince rather than a moment taken from the device's clock, and keep the entityTag alongside it to ask conditionally.
  4. Where login() errors were matched as DecodingError, match responseDecodingFailed(reason:), notFound and unexpectedStatus(code:) instead.
  5. Replace poll(_:token:) with poll(_:) and treat nil as a pending flow rather than catching an error.
  6. Configure a session shared by several accounts with urlCache = nil.
  7. Make sure the servers in use run release 4.12.3 or newer of the notes app.
  8. Adjust scripts which call rainmaker-cli notes and parse its output: plain notes still lists, while the other operations moved into subcommands.

🤖 Generated with Claude Code

i2h3 and others added 16 commits October 6, 2026 11:35
…helper, a status mapping and notes API version 1.4 as the minimum

The notes features move from Server.swift into the new Server+Notes.swift, a public extension of Server following the precedent of Server+Events.swift. The private notesAPIPayload helper is replaced by a set of private helpers every present and upcoming notes feature is built on: makeNotesAPIRequest builds a request for any method below Server.notesAPIRoot or Server.notesAttachmentAPIRoot with an optional JSON body and additional header fields, notesAPIResponse performs it as a data or an upload task and maps its status, and decodeNotesAPIPayload and encodeNotesAPIBody read and write JSON without echoing note contents into error messages. Server gains a documented jsonEncoder beside jsonDecoder, and requireCredentials is internal now so the extension can use it. Attachments get a root of their own because releases of the notes app before 6.1 route them only below the v1.4 segment.

The statuses of the notes API are mapped onto RainmakerError with the X-Notes-API-Versions header as a gate, because the notes app adds it to every response it sends itself while the server or a proxy answering on its behalf does not. A 404 without the header now throws the new appUnavailable(app: "notes") instead of notFound, which is reserved for a missing note or attachment, so a client keeping its own copy never takes a missing app for deleted notes. With the header, 403, 423 and 507 become the new readOnly, locked and insufficientStorage, and a 412 carrying a note becomes the new noteConflict(current:). A 405 becomes the new methodNotAllowed, a 304 is only accepted when a caller asks for it, and everything else including 401 stays unexpectedStatus. HTTPStatus learns 423 Locked, and HTTPURLResponse.notesAPIVersions reads the header for both the gate and the version check.

Notes API version 1.4, which the notes app serves since release 4.12.3 and which exposes the attachment endpoints, is the minimum now. Notes API requests also ignore the local URL cache, so a conditional request sees the server's real answer and a session shared between accounts cannot be answered with another account's notes.

This is a breaking change: RainmakerError gains cases, an absent notes app surfaces as appUnavailable rather than notFound, and notes apps serving only API version 1.3 are refused. The note request tests cover the new minimum and every status mapping with and without the header, and the existing fixtures already advertise 1.4.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ght the notes capability to compare app releases

Note gains path (the server's internalPath, the location of the note's file below the account's files), isShared and shareTypes, together with a public memberwise initializer so that downstream projects such as Cirruscope can build notes for their own tests. The three fields are decoded only if present, falling back to nil, false and no share types, although every notes release serving API 1.4 sends them, so that payloads written by hand still decode. ShareType is a new struct wrapping the number the server's sharing subsystem uses, with named constants for exactly the kinds the notes app queries, so an unknown kind keeps its number rather than failing the decoding of a note; it encodes as that number.

NotesSettings gains noteMode as the new NoteMode enum (rich, edit, preview), showsHiddenFiles (the server's showHidden) and loadsRecentNoteOnStartUp (the server's loadRecentOnStartUp), which only notes 6.1.0 and later send. It now has a custom decoder in which an unknown mode reads as nil instead of failing the lookup, separate encoding keys so the server's names do not leak into the encoded form, Hashable conformance and a public memberwise initializer. Its documentation now names .md as the default suffix.

The Notes capability gains isAppVersion(atLeast:), which compares the app's version component by component as numbers, orders a pre-release before its release and answers false for an absent or unreadable version, plus storesAttachmentsPerNote and supportsAttachmentDeletion, which both rest on attachmentFoldersAppVersion (6.1.0). The notes API version is 1.4 before and after that release, so the app version is the only way to tell where uploaded attachments land and whether they can be deleted. The library itself still only enforces API 1.4.

The notes settings test now also records the capabilities and expects the two newer preferences exactly when the notes app is 6.1.0 or later, so it branches on the release the app store hands out rather than on the server version. Its fixtures were recorded on all four server versions: Nextcloud 31.0.14 and 32.0.11 still get notes 5.0.2 and their settings bodies are unchanged, while 33.0.5 and 34.0.0 now get notes 6.1.0 and send showHidden and loadRecentOnStartUp. The authenticated capabilities fixtures of 33.0.5 and 34.0.0 were re-recorded for notes 6.1.0 and also pick up the Talk patch releases 23.0.11 and 24.0.5 the app store now hands out. The re-recorded note listings of 33.0.5 and 34.0.0 differed only in server-assigned identifiers and order, and the unauthenticated capabilities only in the Talk version, so those were left as they were.

This is additive for library callers, but Note and NotesSettings encode more keys now, which the JSON output of the command line interface shows.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l request which reports an unchanged listing as nil

NoteChanges now carries what the notes app says about a listing in its headers: lastModified from Last-Modified, which is the server's own time at which it started answering and the value the API defines for the next changedSince, entityTag from ETag without quotes and without a W/ prefix, and chunkCursor and pendingCount from X-Notes-Chunk-Cursor and X-Notes-Chunk-Pending, with isComplete reporting whether the cursor is absent. It gains Hashable conformance, a public memberwise initializer so downstream projects such as Cirruscope can fake results, and an encoder which writes absent values as null like NotesSettings does. Its documentation and code sample no longer claim the cursor is not surfaced: they start with Date.distantPast, derive deletions only from a complete response and remember lastModified only after everything was applied.

notes(changedSince:) fills those values, and the new notes(changedSince:ifChangedFrom:) sends the given tag quoted in If-None-Match and returns nil when the server answers 304 Not Modified, because empty changes would read as every note having been deleted. A live Nextcloud 33 with notes 6.1.0 confirmed that the quoted strong tag yields a 304 carrying the version header, that a W/ tag does not match, and that the tag only depends on the body, so it keeps matching while nothing changes even though lastModified advances. Reading HTTP dates moves into Date(httpDate:), which the WebDAV response parsing now uses as well, and String gains quotedEntityTag and unquotedEntityTag.

The fixture canonicalizer now scopes the header fields it keeps by request path like its body rules: responses of the notes app also keep ETag, Last-Modified and X-Notes-Chunk-Cursor with fixed canonical values and X-Notes-Chunk-Pending as recorded, while every other response still drops them, and 412 gained its reason phrase. NotesTests was re-recorded on all four server versions so the listings carry those headers, which also reassigned note identifiers and order in the bodies; the settings and capabilities fixtures came out unchanged. The incremental listing tests assert that lastModified and entityTag are present and that the response is complete. New mock based tests cover reading the headers, the quoted If-None-Match, the 304 handling and a polling sequence, alongside tests for the two string and date conversions, the canonicalizer's scoped headers and the encoding of NoteChanges.

This is additive for library callers, though NoteChanges encodes four more keys now, which the JSON output of the command line interface shows.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stream of the chunks of one pass

notes(changedSince:chunkSize:continuingAfter:) retrieves one chunk of the notes changed since a moment. It sends pruneBefore, chunkSize raised to at least one because the notes app takes zero as a request not to split the response, and chunkCursor only when a cursor is given. It never sends category, because the notes app applies that filter to the identifiers of the last chunk as well, which would make every note outside the category look deleted. Because the cursor is a plain value, a pass interrupted for example by the system suspending an app or a Shortcuts action can be continued later instead of starting over. notes(changedSince:chunkSize:ifChangedFrom:) is the conditional first chunk: it sends the entity tag quoted in If-None-Match without a cursor and returns nil on 304 Not Modified. While nothing changed, the first chunk already is the last one and carries identifiers only, so its tag matches that of the previous pass's last chunk and of an unchunked response alike.

noteChunks(changedSince:chunkSize:) streams every chunk of one pass in order and finishes after the chunk which is complete, or by throwing the first error. It unfolds its elements from a new internal NoteChunkPass actor rather than producing them from a task of its own with onTermination as events(_:) does. That way a chunk is only requested when the consumer asks for the next element, so a slow consumer cannot make the stream pile up every chunk of a pass in memory, which is the reason to ask for chunks in the first place. Every request also runs within the consumer's task, so cancelling that task cancels the request in flight, and leaving the loop requests nothing more. A server answering with the very cursor it was asked to continue from would repeat the same chunk forever, so the stream throws responseDecodingFailed instead. The documentation explains that deletions may only be derived from the last chunk, which alone lists the identifiers of every note including those the earlier chunks sent in full, and that lastModified is only worth remembering once that chunk was applied.

The notes app 6.1.0 source confirms the behaviour this relies on: the chunk cursor keeps the moment the first chunk was requested, the stubs are only appended to the response without a cursor, and an unreadable cursor starts a new pass. NoteChangesRequestTests covers the query items, the clamping, the conditional first chunk, the stream following cursors through a responder keyed on chunkCursor, an error in the middle of a pass, stopping, cancellation between chunks and of a request in flight, which the new SuspendingRequesting double keeps pending until its task is cancelled, and the repeated cursor. NotesTests gains fetchFirstChunk, recorded on Nextcloud 31.0.14, 32.0.11, 33.0.5 and 34.0.0 against the two seeded notes with a chunk size of one.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nally on its entity tag

note(_:) retrieves one note as the server has it now from notes/<id> of the notes API, so a client can refresh a single note, or a Shortcuts action can look one up, without listing every note first. note(_:ifChangedFrom:) sends the given entity tag quoted in If-None-Match, normalized like the conditional listing so that bare, quoted and W/ tags work alike, and returns nil when the server answers 304 Not Modified, which spares the transfer of the note's content while the copy at hand is current. Both are standalone one-shot calls which run within the caller's task and need no further state.

A 404 carrying the notes app's version header is reported as notFound. The notes app 6.1.0 and 5.0.2 sources show that it answers this for an identifier which was never assigned, for a deleted note, and for a file outside the notes folder or one which is not a note, while a 404 without the header still means the app is unavailable. Because the server looks the note up before Nextcloud's NotModifiedMiddleware compares the tag, a deleted note is never hidden behind nil. The documentation names the fields the server derives a note's tag from (title, category, content, modification, favorite and read-only state), because a change of the shares alone does not change the tag.

NotesTests gains fetchNote, fetchUnchangedNote and fetchMissingNote, recorded on Nextcloud 31.0.14, 32.0.11 (notes 5.0.2), 33.0.5 and 34.0.0 (notes 6.1.0). The first two take the identifier and the tag from a listing recorded in the same test, because the fixture of a lookup is filed under the identifier the server assigned, and the 304 of fetchUnchangedNote is a live recording. The new SingleNoteRequestTests covers the path, the absent query, the cache policy, the quoted If-None-Match, nil on 304, 304 on an unconditional lookup, polling until the note changes, the 404 mappings, the version requirement, a body which is not a note and the credentials requirement with MockRequesting. The Serving protocol, the DocC topics, the documentation of Note, Notes.minimumAPIVersion and AGENTS.md list the new methods.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and of the CLI into a group

createNote(title:category:content:modification:isFavorite:) posts a new note to the notes API and returns it as the server stored it, updateNote(_:title:category:content:modification:isFavorite:ifMatching:) puts only the values it is given and, when given an entity tag, sends it quoted in If-Match, normalized like If-None-Match, and deleteNote(_:) deletes a note. All three are standalone one-shot calls which run within the caller's task, so a Shortcuts action can use them without any further state, and cancelling that task cancels the request in flight. Their bodies are internal Encodable data transfer objects in the new folder Requests/Models, NoteCreationRequest and NoteUpdateRequest, which carry the server's field names, leave out nil values because the notes app only changes what a body names, and send moments in whole seconds through Date.wholeSecondsSince1970.

The documentation rests on the notes app 5.0.2 and 6.1.0 sources, whose create, update and destroy are identical in what matters here. The server sanitizes titles and categories into file and folder names, keeps the first line, cuts titles after 100 characters, numbers a title already taken and substitutes the localized "New note" for an empty one, so callers adopt what it returns. Creating is not idempotent, so a retry after a lost response has to look for the note first, while a creation which fails with a response is rolled back by the server. A change applies content, modification, title or category and favorite one after the other and is not atomic, a modification sent with content keeps the client's moment, a rename keeps the identifier, and a favorite needs no write permission because it is stored with the account's favorites. A mismatching entity tag changes nothing and throws noteConflict(current:) with the note the server sent along with its 412, so a caller can reapply its change, for example an append, and retry with the current tag. The server checks no entity tag on delete, so none is offered. Moving and deleting the .attachments.<id> folder along with a note only happens from notes 6.1.0 on, which the documentation ties to Notes.storesAttachmentsPerNote.

The new recorded suite NoteMutationTests covers the lifecycle (create, conditional change, delete, then a lookup reporting the note as not found), the conflict, the favorite, a rename into a category, the sanitized title and the deletion of a missing note, recorded on Nextcloud 31.0.14, 32.0.11 (notes 5.0.2), 33.0.5 and 34.0.0 (notes 6.1.0). Every test deletes the note it creates and pins its moments so the recorded modified values replay, and the suite joins FixtureOrchestrator.recordableSuites. The new NoteWritingRequestTests covers the request bodies and their keys, the omission of nil values and of moments at or before the epoch, If-Match in its three accepted forms, the conflict with and without a note and without the app's header, the read-only, locked, missing, unavailable and storage statuses, the version requirement, cancellation and the credentials requirement with MockRequesting and SuspendingRequesting.

The CLI's notes command is now a group in Commands/Notes with list as its default subcommand, so notes --changed-since keeps working. List gains --chunk-size, --cursor and --if-none-match and prints a #cursor line for an incomplete chunk and "Not modified." for a 304, get retrieves one note, optionally conditionally, and create, update and delete map onto the new methods. The Serving protocol, the DocC topics, the CLI documentation, the documentation of Note, RainmakerError.noteConflict(current:) and Notes.minimumAPIVersion, and the AGENTS.md repository structure and fixture notes cover the additions.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tings command of the CLI to change them

updateNotesSettings(notesPath:fileSuffix:noteMode:showsHiddenFiles:loadsRecentNoteOnStartUp:) puts the given settings to the notes API, which routes this only below version 1, and returns the settings as the server stored them. Like the other notes methods it is a standalone one-shot call which runs within the caller's task, so a Shortcuts action can use it without any further state. Its body is the new internal data transfer object NotesSettingsUpdateRequest in Requests/Models, which carries the server's names notesPath, fileSuffix, noteMode, showHidden and loadRecentOnStartUp and leaves out nil values, because the notes app resets a setting sent as null to its default.

The documentation rests on the SettingsService of the notes app 5.0.2 and 6.1.0 sources. The server never refuses a value but sanitizes it, so callers adopt what it returns: the notes path is resolved below the account's files with an empty path meaning their root folder, a suffix other than .md or .txt is stored as a custom one filtered to letters, digits, dots and hyphens, and a note mode the account is not offered falls back to the server's default. Changing the notes path moves no note, and the notes app 6.1.0 also leaves hidden files and folders out of its listings unless showHidden is set, so both settings change which notes are listed and a client keeping its own copy has to retrieve them anew. Older releases ignore the two settings introduced with 6.1.0, which the returned settings show by leaving them nil. The documentation of NotesSettings.showsHiddenFiles claimed that it only concerned the web interface and now says that it applies to the API listings as well, and that of NotesSettings.fileSuffix now names the extensions the app always reads notes from.

NoteMutationTests gains updateSettings, which looks the settings up, sets the file suffix to the .md the baseline already has and expects every other setting to come back unchanged, recorded on Nextcloud 31.0.14, 32.0.11 (notes 5.0.2), 33.0.5 and 34.0.0 (notes 6.1.0). NoteWritingRequestTests covers the method, the version 1 path, the server's keys, the omission of nil values, an empty body, the unavailable app, the version requirement and the credentials requirement with MockRequesting.

The CLI's notes-settings command gains --notes-path, --file-suffix, --note-mode, --show-hidden-files and --load-recent-note-on-start-up. Any of them makes it change the settings, while none keeps looking them up, and NoteMode becomes a valid option value through an ExpressibleByArgument conformance in the CLI. The Serving protocol, the DocC Notes topic, the CLI documentation, the documentation of NoteMode, NotesSettings and Notes.minimumAPIVersion, and the AGENTS.md repository structure cover the additions.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… notes and taught the notes command of the CLI to manage them

attachment(at:ofNote:) retrieves a file a note refers to into a NoteAttachment, downloadAttachment(at:ofNote:to:force:) streams it into a local file and returns a NoteAttachmentFile, addAttachment(_:toNote:fileName:) uploads a local file or, with a required name, bytes in memory and returns the path the server stored them at, and deleteAttachment(at:ofNote:) deletes one. All of them go to the notes API below v1.4, because releases before 6.1.0 route attachments only there, send the path as the query parameter path, and are standalone one-shot calls which run within the caller's task, so a Shortcuts action can use them without any further state and cancelling that task cancels the request in flight. A download refuses an existing destination before anything is sent unless forced, and puts the file in place only once it arrived completely.

The notes app answers the retrieval without its X-Notes-API-Versions header and with a bare 404 for every failure, so the retrieval does not go through the shared notes helpers: it asks for any type, bypasses the local cache, takes 200 as success, 404 as notFound whether the note, the file or the app is missing, a response of an app older than API 1.4 as unsupportedAPIVersion and anything else as unexpectedStatus. A response without a type is taken as application/octet-stream. Uploading and deleting go through the shared helpers. The upload is a multipart/form-data body with the field file, written by the new internal MultipartFormData into a staged file through Server.stagingBufferSize, with quotes in the file name escaped as %22 and line breaks removed, sent through an upload task and removed in a defer whatever the outcome. The documentation rests on the notes app 5.0.2 and 6.1.0 sources: 6.1.0 stores an upload as .attachments.<id>/<name>, numbering a name which is taken, refuses invalid names with 400 and deletes only by the last path component within that folder, removing it once empty, while 5.0.2 stores a flat random name keeping only the extension and offers no deletion, which the server refuses with 405 and which surfaces as methodNotAllowed. Because the retrieval may still be stored in a session's URLCache, the documentation of Server.init and of the attachment methods recommends a session without a cache, cookie storage and cookies for sessions shared by several accounts.

Fixed: makeAppRequest(for:method:queryItems:) and makeOCSRequest(for:method:queryItems:) now percent-encode +, & and = in query names and values as well, through percentEncodedQueryItems. URLComponents.queryItems leaves them as they are, so the server read a + as a space, and an attachment named a+b.png would have been looked up as a b.png. Queries of letters, digits and the other characters a query allows are unchanged, which RequestFactoryTests proves along with the fix.

The test infrastructure records and replays the body of an upload when it has one, while WebDAV uploads keep having no body file, MockRequesting answers downloads and records the files uploads are sent from, and SuspendingRequesting suspends uploads as it suspends data requests. The new NoteAttachmentRequestTests covers the version 1.4 paths, the encoding of reserved and non-ASCII characters in paths, the accepted type and cache policy of the retrieval, the multipart body and its escaping, the removal of the staged body also after cancellation, the download destination rules and the statuses with mocks. The new recorded suite NoteAttachmentTests covers the lifecycle, the download and a missing attachment on Nextcloud 31.0.14, 32.0.11 (notes 5.0.2), 33.0.5 and 34.0.0 (notes 6.1.0), branching on the recorded capabilities, and cleans up after itself, deleting the attachment over WebDAV where the notes app cannot. It joins FixtureOrchestrator.recordableSuites.

The CLI's notes group gains the attachment group with get, which writes the file with --output and --force through downloadAttachment and reports the type and size otherwise, add, which prints the stored path, and delete. The Serving protocol, the DocC Notes and request factory topics, the CLI documentation, the README summary, the documentation of Notes.minimumAPIVersion and the AGENTS.md repository structure and fixture notes cover the additions.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reference its attachments the way the notes and Text apps do

The new folder Sources/Rainmaker/NoteContent holds four public caseless enums which need neither a Server nor a connection, so that a downstream app such as a Shortcuts action creating a note from text or adding an image to a note does not have to reimplement conventions of the notes app and of the Nextcloud Text app. NoteTitle.derive(fromContent:) returns the title the notes app's web interface gives a note for its content, which the notes API never derives itself, and NoteTitle.sanitize(_:) the title the server makes of a typed one. NoteCategory.sanitize(_:) returns the category the server files a note under. NoteAttachmentReference encodes and decodes the target of a Markdown reference to an attachment, builds the whole reference with markdown(alt:path:) and removes the characters controlling the direction of text from a file name with sanitizeFileName(_:). NoteAttachmentPath.resolve(_:relativeTo:) resolves the path of an attachment relative to the folder of a note's category to the path relative to the notes folder which the server serves.

The rules were re-implemented from observed behaviour of the notes app 5.0.2 and 6.1.0, whose title and category handling is identical, and of Text, without copying the AGPL-licensed sources, and they were checked against that behaviour for more than 16000 generated inputs. Titles strip list markers, headings, setext underlines and emphasis, remove * | / \ : " < > ?, strip leading dots and white space, keep the first line, turn every white space character into a space without collapsing runs, and cut after 100 Unicode scalars. A derived title is sanitized twice as the server does when it names the file, which trims a space left by the cut, and "0" counts as empty as it does for the server. White space is the fixed list of the notes app's pattern matching, which includes the next line control and the Mongolian vowel separator. The removal of emphasis and headings runs in linear time, so content on which a backtracking matcher stalls cannot stall a caller. The documentation says that the server sanitizes again, may append a number such as " (2)" and may remove emoji depending on its database, which the helpers do not, so callers still adopt the returned Note.title and Note.category.

References are encoded per path component with only ASCII letters, digits and - _ . ~ left as they are and upper case hexadecimal otherwise, which equals encodeURIComponent plus escaping ! ' ( ) *, as Text writes them and as its cleanup expects them. Decoding keeps + and tolerates references which are not encoded. The documentation explains that adding an attachment does not change the note, that the reference has to be inserted into its content, and that Text's background job deletes files in a Markdown note's .attachments.<id> folder which its content does not reference.

The new table-driven suites NoteTitleTests, NoteCategoryTests, NoteAttachmentReferenceTests and NoteAttachmentPathTests cover Unicode, decomposed letters, emoji, numbered names, &, +, %, spaces, headings, lists, emphasis, setext underlines, empty input, the 100 scalar cut-off, .. traversal and pathological content without fixtures. The documentation of createNote, attachment(at:ofNote:) and addAttachment refers to the helpers, and the DocC landing page gains a Note Content topic, while the README summary and the AGENTS.md repository structure cover the additions.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… single response, conditionally and in chunks

noteSummaries(changedSince:), noteSummaries(changedSince:ifChangedFrom:), noteSummaries(changedSince:chunkSize:continuingAfter:), noteSummaries(changedSince:chunkSize:ifChangedFrom:) and noteSummaryChunks(changedSince:chunkSize:) mirror the listing family of notes one to one, but add exclude=content to every request, so a client which does not need the text of the notes, such as one which deliberately keeps no copy of it, never downloads it. They are standalone one-shot calls which run within the caller's task, and the stream requests each chunk only when the consumer asks for it, as noteChunks does. The title is never excluded, because its presence is what tells a note sent in full from one reduced to its identifier.

The new public NoteSummary model carries every field of Note but content, hasError and errorType, with the same decoding and encoding keys and a public memberwise initializer. The notes app 6.1.0 and 5.0.2 sources show that with the content excluded it still sends id, etag, readonly, title, category, favorite, modified, internalPath, shareTypes and isShared, along with error false and an empty errorType, because a failure to read a note is only detected while reading its content; a summary therefore has no error field, which could only ever claim that every note was readable, and its readonly is never forced by such a failure. The etag is the same per-note tag a full note carries, since the server derives it from a digest of the content it keeps, so a summary's entityTag works with note(_:ifChangedFrom:) and updateNote(ifMatching:), and a note whose content alone changed is listed as changed. Pruned stubs still carry only the id. NoteSummaryChanges mirrors NoteChanges with summaries in place of notes; its entityTag describes a body without text and is documented as matching later summary listings only.

The listings of notes and of summaries share one private implementation instead of duplicating it. The internal protocol NoteChangeSet, to which NoteChanges and NoteSummaryChanges conform in extensions that also decide whether the content is excluded, lets the request helpers and the reading of headers and bodies be generic, while NoteEntry is now generic over the type a note sent in full is decoded as and NoteChunkPass over the chunk type, retrieving through a closure. The public methods stay concrete and documented one by one, and the existing listings behave as before.

NotesTests gains fetchSummaries and fetchSummariesFirstChunk, recorded on Nextcloud 31.0.14, 32.0.11 (notes 5.0.2), 33.0.5 and 34.0.0 (notes 6.1.0). The new NoteSummaryRequestTests covers the exclude query item of every variant and its absence from the listings with text, the decoding of summaries alongside identifiers, a response which carries the content anyway, a malformed summary, the headers and chunk headers, nil on 304, 304 on unconditional requests, the status mapping, the credentials requirement, the stream following cursors and the cancellation of a request in flight with MockRequesting and SuspendingRequesting. ModelEncodingTests covers the encoded keys of both new models. The CLI's notes list gains a --summaries flag. The Serving protocol, the DocC Notes topic, the documentation of Note, NoteChanges, Notes.minimumAPIVersion, README and AGENTS.md mention the new methods and types.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…transport fall back to polling when the WebSocket keeps failing to connect

The new public ServerEventTransport selects how Server.events(_:) learns about changes and is set through ServerEventOptions.transport, which is added as the last initializer parameter with the default automatic, so existing callers compile and behave as before on every platform but watchOS. With polling, the stream polls every subject at the poll interval and never requests the capabilities or opens a WebSocket, so it sends no requests of its own, which suits a consumer that only needs a periodic re-fetch hint or runs where a long-lived socket is unwanted, such as a short-lived extension. The documentation explains that such a stream leaves noticing rejected credentials to the requests the consumer makes in response to the hints.

The automatic transport now polls on watchOS, because the system only grants WebSocket connections there in narrow circumstances such as an active audio streaming session, as Apple's technote TN3135 describes. ServerEventCoordinator.platformSupportsWebSocket decides this at compile time.

Fixed: the coordinator never fell back to polling when an advertised notify_push endpoint could not be reached, for example behind a proxy which does not forward WebSocket upgrades. It retried the socket forever with a backoff of up to a minute and delivered no events in between. It now counts consecutive connections which ended before authenticating and, after three of them, polls for the rediscovery interval before looking at the capabilities again. A connection which authenticated resets the count, and so does the fallback itself. A poll window with nothing to poll, because no subjects were requested or the interval is not positive, now waits out the window instead of returning at once, so the loops over it cannot spin.

SessionOutcome and PushTarget moved out of ServerEventCoordinator.swift into files of their own, following the rule of one type per file, without changes in behaviour.

ServerEventsTests gains tests proving that the polling transport neither requests the capabilities nor opens a socket, that repeated connection failures fall back to polling, that an authenticated connection resets the count, and, compiled for watchOS only, that the automatic transport polls there. The tests which need a socket are enabled only where the platform supports it. MockWebSocketConnecting counts the channels it vends for these assertions. The CLI's watch command gains --transport automatic|polling. The documentation of events(_:), ServerEventOptions and the webSocket parameter of Server.init, the DocC Observing Changes topic, the CLI abstract and AGENTS.md describe the transports and the fallback.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…made beginning a login flow report what the server actually answered

The new Server.poll(_:) takes the LoginFlow that Server.login() returned and returns nil while the user has not completed the flow, which the server answers with the status 404 and an empty JSON array. Every other outcome is either the LoginResult or an error: a 404 without that array becomes notFound, any other status than 200 becomes unexpectedStatus, and a 200 without credentials becomes responseDecodingFailed. Before, the only way to poll was poll(_:token:), which threw responseDecodingFailed while the flow was pending, so a caller could not tell a pending flow from a real failure. A consumer that polls with try? and treats every error as pending, as Cirruscope's login session does, kept polling an unreachable server or a wrong endpoint until its timeout and never told the user why. The server cannot tell a pending flow from one which expired, whose token is wrong or whose result was already fetched, so poll(_:) returns nil for those as well, and its documentation says so together with the server's lifetime of 20 minutes for a flow, after which a caller should stop polling.

poll(_:token:) is deprecated and renamed to poll(_:), with its behaviour unchanged. Both now send the token through one private helper, percent-encoded as a form field with an explicit application/x-www-form-urlencoded content type. The tokens the server hands out consist of letters and digits only, so the request body is the same as before.

Changed: Server.login() now checks the status of the response. A 404 throws notFound, any other status than 200 throws unexpectedStatus, and a body which is not a login flow throws responseDecodingFailed with a message saying that the server is probably not a Nextcloud server, where it used to throw a raw DecodingError or decode whatever a non-200 response carried. This is a breaking change for callers which matched DecodingError.

LoginFlow and LoginResult gain public initializers, so that a client can poll a flow it kept across launches and tests and previews can fake a result, and both now conform to Hashable. The documentation of LoginResult.name explains that it is the login name the user typed, which can differ from the identifier of the account that WebDAV paths and avatars are keyed by, and LoginResult.server that it is the address the server derives from the browser's request and its own configuration.

The CLI's poll command uses poll(_:) and sleeps and retries on nil, so it no longer swallows responseDecodingFailed and ends with the error of a real failure.

LoginRequestTests covers both calls with MockRequesting: an HTML page answered with 200, a 404 and a 500 for login(), and for poll(_:) the form body and its encoding, a pending flow, a result, a 404 without the array, a 503, an undecodable 200 and, with SuspendingRequesting, that cancelling the calling task cancels the request. It also proves the deprecated method's unchanged behaviour. PollTests gains pollingPending and pollingResult on hand-authored fixtures copied from the existing ones to all four versions, and keeps the deprecated-path tests, which call through Serving, whose requirement is deliberately not deprecated, so they compile without warnings. LoginTests now checks the returned flow. The DocC Authentication topic lists poll(_:) and AGENTS.md describes the new tests.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ated account and taught the CLI to show them

The new Server.currentUser() asks the provisioning API at ocs/v2.php/cloud/user for the details of the account a Server authenticates as and returns them as the existing User, with the identifier the server keys the account by and its display name. That identifier is not necessarily the login name in Server.user or LoginResult.name, because a server can accept an email address or a login attribute of an LDAP directory as the login name. Server.userAvatar(_:size:darkTheme:) expects the identifier, so its documentation now says to pass User.id from currentUser() rather than the login name, and the documentation of Server.user, LoginResult.name and User.id explains the difference. The WebDAV paths of this library are still built from Server.user and therefore only work for accounts whose login name is their identifier; this is documented as a limitation and not changed here.

currentUser() is a single stateless request which runs within the calling task, so it can be used on its own, for example in a Shortcuts action right after a login flow completed. It requires credentials and throws credentialsRequired without them. Every status other than 200, including the 401 for rejected credentials, becomes unexpectedStatus, because the route is part of every installation and a 404 has no meaning of its own there. A body which is not the details of an account becomes responseDecodingFailed, and so does a failed OCS status. The internal CurrentUserResponse decodes only the identifier and the display name, so that a change to any of the many other details the server sends cannot break the lookup.

User gains a public initializer, so that tests and previews of a consumer can fake one, which follows the house rule for public models a downstream project may want to fake.

FixtureCanonicalizer replaces the moments of the first and the last login and the free, used, total and relative numbers of the quota with fixed values in responses below /cloud/user, because they change with every recording. A live recording on all four versions showed no other volatile fields. CurrentUserTests records requireCredentials and fetch on Nextcloud 31.0.14, 32.0.11, 33.0.5 and 34.0.0 and is now one of the recordable suites, CurrentUserRequestTests covers the request, an account whose identifier differs from its login name, the statuses, undecodable bodies and the cancellation with MockRequesting and SuspendingRequesting, and FixtureCanonicalizerTests covers the new rules and that they apply nowhere else.

The CLI gains the current-user command, which prints the identifier and the display name separated by a tab or the User as JSON. The DocC catalog gains a Current User topic and mentions the identifier in the User Avatars topic, CommandLineInterface.md lists the new command, the README mentions the current user and AGENTS.md describes the new response model and tests.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d on using them from App Intents, regenerated help listings of the CLI and a full re-recording of the fixtures

The new DocC article SynchronizingNotes explains how a client keeps its own copy of the notes in step with the server: checking the notes capability and its release gating, passing the server's lastModified as changedSince rather than a note's modification or the device's clock, asking conditionally with the entity tag, retrieving a pass in chunks and deriving deletions only from its complete last chunk, listing summaries without the text and keeping their response tags apart, changing notes with If-Match and resolving noteConflict(current:), the missing idempotency of creating notes and attachments, attachments with their v1.4 segment, the per-note folders of release 6.1.0 and Text's reference encoding and cleanup, settings which change the listings, and a session without URLCache for several accounts. The new article UsingNotesFromAppIntents shows standalone one-shot calls for Shortcuts-style actions: creating a note from text with NoteTitle.derive(fromContent:), appending with a retry on noteConflict(current:), adding an image through addAttachment and NoteAttachmentReference.markdown(alt:path:), bounding time and cancellation through the injected session and the calling task, and indexing notes through summary chunks. Both are linked from the Notes topic, and the Handling Errors topic now names appUnavailable, unsupportedAPIVersion and methodNotAllowed as well.

The verbatim help listings in CommandLineInterface.md were regenerated from the actual output of the CLI without line wrapping, for the top-level command, the notes and notes attachment groups, notes-settings, current-user, poll and watch. The top-level listing now also shows record-fixtures, which the CLI lists on macOS. The README gained a Requirements section with the toolchain, the platforms, the verified server versions and the notes app releases the notes features need, and its summary line now covers the synchronization features. AGENTS.md now describes the two articles, the help regeneration, ShareType, the CLI command layout and current-user, and its fixture section covers how long a full recording takes, the recorder's flags and the "Recording 0 test(s)" pitfall, the churn every recording produces and that the notes settings are not reset between tests.

Two doc comments of the CLI linked library symbols DocC cannot resolve from the CLI target and now name them as code instead. The documentation of NoteMarkdownStripper and AGENTS.md no longer claim that every pass runs in linear time: the removal of emphasis does so for realistic content, but its worst case grows with the length of a line times its square root.

All recordable suites were re-recorded on Nextcloud 31.0.14, 32.0.11, 33.0.5 and 34.0.0, with the notes app 5.0.2 on the first two and 6.1.0 on the others, and every test passed against the live servers. Almost every change was churn and was reverted: new note identifiers and fixture folders, the order of WebDAV listings and of the first chunk of notes, upload times, Talk and login flow tokens and Talk patch releases in the unauthenticated capabilities. The only fixtures kept are the conflictWhenExists MOVE headers of MoveTests, which now carry the reason phrase "Precondition Failed" the canonicalizer writes since it learned 412.

The documentation build reports no warnings for the library, and the tests build for the iOS, tvOS, watchOS and visionOS simulators. This commit changes no public API, so it does not affect the version on its own; the release it prepares is 5.0.0 because of the breaking changes on this branch.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
downloadAttachment(at:ofNote:to:force:) no longer replaces a directory at the destination. With force set, it used to skip every check and hand a directory to FileManager.replaceItemAt, which deleted the directory together with everything in it, so for example `notes attachment get 7 photo.png --output ~/Downloads --force` deleted ~/Downloads. The new internal FileManager.assertNotDirectory(at:) refuses a directory with fileAlreadyExists whether force is set or not, before the request is sent and once more right before the file is put in place, so a directory which appeared during the download is caught too. The documentation of the method and the --output and --force help of the CLI now say that only a regular file is ever replaced.

A 200 response without the X-Notes-API-Versions header is now reported as responseDecodingFailed rather than as unsupportedAPIVersion with an empty list. Only the notes app sends that header, so such a response is a login page of an authenticating proxy or a maintenance page, and reporting it as an outdated app told users to update an app which was fine. A non-empty header which lists only older versions is still unsupportedAPIVersion. The status mapping of notesAPIResponse, the decoding helper and the Throws lists of the mutating calls describe this now. NotesRequestTests.missingVersionHeader expects the decoding failure, a new test sends a login page without the header, and the comment of malformedResponse no longer claims to cover a proxy page.

A notes app older than 4.12.3 carries out a creation, a change, a deletion or a change of its settings before its answer reveals its version, so createNote, updateNote, deleteNote and updateNotesSettings throw unsupportedAPIVersion after the change was made. Their Throws lists and Notes.minimumAPIVersion now say so and point to Notes.isSupported. The article on App Intents gained a section which checks the Notes capability before changing anything, its intent creating a note does so, and its paragraph about errors no longer suggests that nothing happened. New tests pin that the request was sent in these cases. A pre-flight check before every mutation was not added, because Server keeps no capabilities and it would cost every one-shot call another round trip.

Staging the multipart body of addAttachment(_:toNote:fileName:) from a file now checks for cancellation before every pass of the copy and once more before the upload starts, so a cancelled intent no longer copies a large file to the end first. Its documentation says that staging throws CancellationError then and needs free space about the size of the source.

The event coordinator now resets the count of failed WebSocket connections on an authentication rejection, which proves that the socket reached the server, and whenever it falls back to polling, also when discovery found no push target or authentication kept failing, and it restores the initial backoff on those fallbacks. Failures which were not consecutive no longer add up to a poll window of ten minutes, as the documentation of maximumConnectionFailures and Server.events(_:) already promised. A new test interleaves an authentication rejection between connection failures.

The general statements that an absent notes app is never reported as notFound now name the exception of retrieving an attachment, which reports every failure including an absent app as notFound because the notes app answers it with a bare 404: in RainmakerError.appUnavailable and notFound, in the documentation of notes(), in the overview and in the articles on synchronizing notes and on App Intents, the latter no longer saying that notFound always means a deleted note.

Further documentation fixes: a custom file suffix with an upper-case letter or a further dot, such as .TXT2 or .tar.gz, is never recognized by the notes app, so notes created with it are neither listed nor found; releases of the notes app older than 6.1.0 answer a path naming a folder, an empty one included, with 500 rather than 404; the extra encoding of !, ', (, ) and * in attachment references is the Text app's, not the plain editor's; attachments are listed in the folder which contains the file Note.path names rather than in a folder it names; and the showsHiddenFiles parameters of updateNotesSettings and NotesSettings.init say that the setting decides what the API lists as well.

The plain output of `notes list` now ends a listing of changes with `#etag` and `#last-modified` lines next to `#cursor`, and `notes get` writes the note's `#etag` line to standard error, so that the values --if-none-match and --changed-since take are available without the JSON output. The help of those options and CommandLineInterface.md name the lines.

MockRequesting records the temporary files it hands downloads over in, and SuspendingRequesting can suspend downloads on request. New tests cover a file and a directory which appear during a download, a directory as the destination with force set, the cancellation of a download and of staging, and that neither the session's temporary file nor a staged copy is left behind. AGENTS.md describes the extended doubles.

The draft release notes gained the watchOS and polling consequence for rejected credentials in events(_:), Notes.attachmentFoldersAppVersion, the attachment exception to notFound, the side effect on outdated apps and the other behaviour above. All of this changes behaviour introduced on this branch for 5.0.0 and adds no public API, so the release stays 5.0.0.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xtures

The canonicalizer only matched the login URL with plain slashes, but the server's JSON escapes them as \/, so the flow tokens of the recorded login responses were never redacted. It now matches the escaped form as well, a test covers it, and the existing fixtures carry REDACTED in place of those tokens. The hand-authored poll fixtures, including the new ones for poll(_:), now use REDACTED in place of the app password they repeated, as no test depends on its value.

Signed-off-by: Iva Horn <contact@cirruscope.app>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@i2h3 i2h3 added this to the 5.0.0 milestone Oct 6, 2026
@i2h3 i2h3 added the enhancement New feature or request label Oct 8, 2026
@i2h3 i2h3 self-assigned this Oct 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant