Class: AtlasRb::Work

Inherits:
Resource show all
Defined in:
lib/atlas_rb/work.rb

Overview

The bibliographic unit in Atlas — an article, thesis, dataset, image, etc.

A Work belongs to exactly one Collection and aggregates one or more FileSets, each of which holds binary content via a Blob. MODS metadata is attached at the Work level.

See also: Collection, FileSet, Blob.

Constant Summary collapse

ROUTE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Atlas REST endpoint prefix for this resource.

"/works/"
ASSOCIATION_TYPES =

The five relationship predicates Atlas accepts, carried here so a caller can build a select box without hard-coding the vocabulary. Adding a sixth needs an Atlas release, so this list cannot drift ahead of the server.

%w[
  is_codebook_for
  is_figure_for
  is_instructional_material_for
  is_supplemental_material_for
  is_transcription_of
].freeze

Constants included from FaradayHelper

FaradayHelper::ASSERTION_AUDIENCE, FaradayHelper::ASSERTION_ISSUER, FaradayHelper::ASSERTION_TTL, FaradayHelper::INSTRUMENTATION_EVENT

Class Method Summary collapse

Methods inherited from Resource

descendant_works, find_many, history, mods_version, mods_versions, permissions, preview

Methods included from FaradayHelper

#connection, #multipart, #system_connection, #with_file_part

Class Method Details

.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

Add a linked membership: surface a Work in an additional Collection.

Wraps POST /works/<id>/linked_members with a collection_id body. This does not move the Work — its structural parent (a_member_of) is untouched; the Collection is added to a_linked_member_of. Atlas enforces two-sided authorization (edit on the Work and the target Collection) and the structural guards, surfacing failures as a 422. Permissions are never changed by this call.

Examples:

AtlasRb::Work.add_linked_member("w-789", "col-456")
# => ["col-456"]

Parameters:

  • work_id (String)

    the Work ID.

  • collection_id (String)

    the Collection to link the Work into.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    the Work's full set of linked Collection noids after the add — the affected sub-resource, so no follow-up linked_members GET is needed.

Raises:



774
775
776
777
778
779
# File 'lib/atlas_rb/work.rb', line 774

def self.add_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
  write_resource(
    connection({ collection_id: collection_id }, nuid, on_behalf_of: on_behalf_of)
      .post(ROUTE + work_id + '/linked_members')
  )
end

.assets(id, nuid: nil, on_behalf_of: nil) ⇒ Array<AtlasRb::Mash>

List the assets attached to a Work — Blobs and Delegates alike.

Useful for building download UIs — the response includes enough to render each entry's display name, size or uri, and download URL. The shape is polymorphic: Blob-backed entries carry fields like size, while Delegate-backed entries carry uri. Callers should duck-type on the field they need rather than expecting a single schema.

Every entry (Blob and Delegate) also carries the advisory read gate set via set_derivative_permissions: gated (true if the asset must be authorized rather than fetched directly) and permission (the effective read-group set, or nil for guests, to whom group names are withheld).

Examples:

AtlasRb::Work.assets("w-789").each { |a| puts a.label }

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<AtlasRb::Mash>)

    the listing from GET /works/<id>/assets, one entry per attached asset.



609
610
611
612
613
# File 'lib/atlas_rb/work.rb', line 609

def self.assets(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/assets')&.body
  ).map { |entry| AtlasRb::Mash.new(entry) }
end

.associate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil) ⇒ Hash

Assert that this Work stands in a typed relationship to another Work.

Wraps POST /works/<id>/associations with a work_id + type body. The edge is stored on this Work only; target reports the same edge under inbound. Asserting an edge that already exists is a no-op, and two Works can hold several different edges at once.

A cycle is permitted and meaningful — "A is a transcription of B" and "B is a figure for A" can both be true.

Examples:

AtlasRb::Work.associate("w-789", "w-123", type: "is_codebook_for")
# => {"outbound" => {"is_codebook_for" => ["w-123"]}, "inbound" => {}}

Parameters:

  • work_id (String)

    the asserting Work's ID (the codebook, figure, …).

  • target_id (String)

    the Work being pointed at (the dataset, article, …).

  • type (String)
  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the Work's associations after the add, in the associations shape — so no follow-up GET is needed.

Raises:

  • (AtlasRb::WorkAssociationError)

    if Atlas rejects the claim (HTTP 422): unknown type, unresolvable target, a non-Work target, the Work itself, or either end tombstoned. The envelope's error code is exposed as #code.

  • (AtlasRb::ForbiddenError)

    if Atlas refuses the write (HTTP 403). Associating is admin / devolved-admin only, because the claim renders on the target's page too.

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such Work, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



902
903
904
905
906
907
# File 'lib/atlas_rb/work.rb', line 902

def self.associate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil)
  write_resource(
    connection({ work_id: target_id, type: type }, nuid, on_behalf_of: on_behalf_of)
      .post(ROUTE + work_id + '/associations')
  )
end

.associations(id, nuid: nil, on_behalf_of: nil) ⇒ Hash

List a Work's typed associations with other Works.

Wraps GET /works/<id>/associations. An association is DRS v1's "associated works": a directed claim that one object is the codebook, figure, transcription, instructional material or supplemental material for another. Both objects stay separate records — this is not membership, and nothing moves in the containment tree.

The edge is stored once, on the Work that asserts it. Atlas derives the other direction, so outbound and inbound can never disagree.

Examples:

AtlasRb::Work.associations("w-789")
# => {"outbound" => {"is_codebook_for" => ["w-123"]}, "inbound" => {}}

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    {"outbound" => {predicate => [noid, …]}, "inbound" => {…}}. outbound is what this Work asserts, inbound what other Works assert about it. Predicates holding no edges are omitted, so both maps are {} for an unassociated Work.



860
861
862
863
864
# File 'lib/atlas_rb/work.rb', line 860

def self.associations(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/associations')&.body
  )
end

.clear_incomplete(id, nuid: nil, on_behalf_of: nil) ⇒ Hash

Clear the incomplete flag and its reason.

The repair half of mark_incomplete: call it from the same job when a later run succeeds, or by hand once an operator has fixed the Work. Both fields clear together — a reason without a flag would leave a stale cause on the Solr document.

Idempotent: clearing a Work that was never flagged is a no-op.

Examples:

On a later successful run

AtlasRb::Work.clear_incomplete(work_id)

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional NUID of the acting user.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the updated Work, the same shape find returns, with incomplete false and incomplete_reason null.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



339
340
341
342
343
344
# File 'lib/atlas_rb/work.rb', line 339

def self.clear_incomplete(id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .delete(ROUTE + id + '/incomplete')
  ))["work"]
end

.complete(id, nuid: nil, on_behalf_of: nil) ⇒ Faraday::Response

Mark a Work complete.

Cerberus's bulk-deposit job calls this once it has confirmed all expected children (FileSets / Blobs) are deposited. Atlas's monitoring query GET /works?in_progress=true then drops this Work from the "stuck" list.

Idempotent on the server: calling complete on an already-complete Work is a no-op — Atlas re-saves with in_progress: false. Atlas does not currently stamp a completed_by audit field; the nuid: parameter is plumbed through for parity with the other lifecycle bindings and in case Atlas adds completion audit later.

This call also mints the Work's persistent identifier. Atlas registers <prefix>/<noid> with its Handle service, pointed at the public Work page, and records it as handle on the Work. Two consequences for a caller:

  • Minting can never fail the call. A handle server that is down, slow or unconfigured leaves handle null and the Work still complete — never a non-2xx. So a 200 does not promise a handle: the response body carries the Work, so check handle on it rather than assuming success minted one.
  • Re-completing is safe. Atlas mints only when handle is empty, and the underlying registration is keyed by handle name, so a repeat call re-points rather than minting a second identifier.

A deployment with no handle server configured mints nothing at all, which is the normal state for a stack brought up without it.

Examples:

AtlasRb::Work.complete("w-789")

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional NUID of the acting user.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Faraday::Response)

    the raw response. Status 200 on success.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").



260
261
262
# File 'lib/atlas_rb/work.rb', line 260

def self.complete(id, nuid: nil, on_behalf_of: nil)
  connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/complete')
end

.create(id, xml_path = nil, idempotency_key: nil, nuid: nil, on_behalf_of: nil, depositor: nil) ⇒ Hash

Create a new Work in an existing Collection.

Note: unlike Community.create and Collection.create, the id parameter here is the parent Collection ID. The underlying request uses the collection_id query param rather than parent_id.

Examples:

Empty work, metadata to be added later

AtlasRb::Work.create("col-456")

Work seeded from MODS

AtlasRb::Work.create("col-456", "/tmp/work-mods.xml")

Retry-safe bulk-deposit create

key = SecureRandom.uuid
AtlasRb::Work.create("col-456", idempotency_key: key)

Proxy deposit — librarian uploads on behalf of a researcher

AtlasRb::Work.create("col-456", depositor: "000000123")

Parameters:

  • id (String)

    the parent Collection ID.

  • xml_path (String, nil) (defaults to: nil)

    optional path to a MODS XML file. When given, the Work is created and immediately patched with the metadata in the file.

  • idempotency_key (String, nil) (defaults to: nil)

    optional UUID. A repeat call with the same key returns the originally-created Work instead of creating a new one (or 410 if it has since been tombstoned, or 410 with no body if it has been hard-deleted). Keys are scoped to the acting user and only apply to the initial POST /works — the optional follow-up PATCH/GET when xml_path is given do not carry the key. The caller (e.g. Cerberus's Solid Queue job) generates and persists the UUID; this gem does not mint keys.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

  • depositor (String, nil) (defaults to: nil)

    optional NUID to stamp on the new Work's depositor field. When omitted, Atlas defaults the depositor to the acting user (nuid:); this kwarg is the proxy / batch escape hatch where the librarian who uploaded the Work is distinct from the person it should be attributed to. The acting user becomes the Work's proxy_uploader. The depositor is immutable post-create; there is no setter on the update surface.

Returns:

  • (Hash)

    the created Work payload (post-update if xml_path was supplied).

Raises:



135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/atlas_rb/work.rb', line 135

def self.create(id, xml_path = nil, idempotency_key: nil, nuid: nil,
                on_behalf_of: nil, depositor: nil)
  params = { collection_id: id }
  params[:depositor] = depositor if depositor
  result = AtlasRb::Mash.new(write_resource(
    connection(params, nuid,
               on_behalf_of: on_behalf_of, idempotency_key: idempotency_key).post(ROUTE)
  ))["work"]
  return result if xml_path.to_s.empty?

  update(result["id"], xml_path, nuid: nuid, on_behalf_of: on_behalf_of)
  find(result["id"], nuid: nuid, on_behalf_of: on_behalf_of)
end

.disassociate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil) ⇒ Hash

Retract one typed relationship between two Works.

Wraps DELETE /works/<id>/associations/<type>/<target_id> — the type is a path segment because it is part of the edge's identity, so retracting the figure claim leaves a transcription claim between the same two Works standing. Idempotent: retracting an edge that was never asserted is a no-op.

Examples:

AtlasRb::Work.disassociate("w-789", "w-123", type: "is_codebook_for")
# => {"outbound" => {}, "inbound" => {}}

Parameters:

  • work_id (String)

    the asserting Work's ID.

  • target_id (String)

    the associated Work to drop.

  • type (String)

    the predicate to retract, one of ASSOCIATION_TYPES.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the Work's remaining associations, in the associations shape.

Raises:



939
940
941
942
943
944
# File 'lib/atlas_rb/work.rb', line 939

def self.disassociate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil)
  write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .delete(ROUTE + work_id + '/associations/' + type.to_s + '/' + target_id)
  )
end

.file_sets(id, nuid: nil, on_behalf_of: nil) ⇒ Array<AtlasRb::Mash>

List a Work's page FileSets in order, each with its assets.

Wraps GET /works/<id>/file_sets — the ordered, grouped sibling of assets (which flattens FileSet membership away). One entry per page-bearing FileSet, sorted position ascending with unordered (null-position) FileSets last; metadata and derivative-container FileSets are excluded as entries. Each entry nests its downloadable assets — the page's content Blobs plus any per-page IIIF Delegates — in the same polymorphic shape assets returns.

This is the read a IIIF Presentation manifest assembler needs: the response is unpaginated by design, so the whole page sequence arrives in one call.

Examples:

Assemble manifest canvases in page order

AtlasRb::Work.file_sets("w-789").each do |page|
  iiif = page.assets.find { |a| a["uri"] }
  add_canvas(order: page.position, image: iiif&.uri)
end

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<AtlasRb::Mash>)

    one entry per page FileSet, in page order: { "noid", "type", "position", "tombstoned", "assets" => [...] }.



644
645
646
647
648
# File 'lib/atlas_rb/work.rb', line 644

def self.file_sets(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/file_sets')&.body
  ).map { |entry| AtlasRb::Mash.new(entry) }
end

.find(id, nuid: nil, on_behalf_of: nil) ⇒ Hash?

Fetch a single Work by ID.

Examples:

AtlasRb::Work.find("w-789")
# => { "id" => "w-789", "title" => "An Article", ... }

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header (acting-as / view-as). Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash, nil)

    the "work" object, already unwrapped from the JSON response, or nil when the Work does not exist (404).

Raises:

  • (AtlasRb::ResourceError)

    on any non-2xx other than 404 / 410 (e.g. an auth/validation error envelope), carrying Atlas's status + body.



33
34
35
36
# File 'lib/atlas_rb/work.rb', line 33

def self.find(id, nuid: nil, on_behalf_of: nil)
  body = fetch_resource(ROUTE + id, nuid: nuid, on_behalf_of: on_behalf_of)
  body && AtlasRb::Mash.new(body)["work"]
end

.linked_members(id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

List the Collections a Work is a linked member of.

Wraps GET /works/<id>/linked_members. Linked membership is the DAG overlay — a Work has exactly one structural parent (a_member_of, set by create / reparent) but may additionally appear in any number of other Collections as a linked member (a_linked_member_of). This returns just those linked Collection noids; the structural parent is not included.

Examples:

AtlasRb::Work.linked_members("w-789")
# => ["col-456", "col-457"]

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    linked Collection noids (possibly empty). The shape mirrors Collection.children — a bare array of ids, not an envelope.



732
733
734
735
736
# File 'lib/atlas_rb/work.rb', line 732

def self.linked_members(id, nuid: nil, on_behalf_of: nil)
  JSON.parse(
    connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/linked_members')&.body
  )
end

.list(in_progress: nil, incomplete: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

List Works, paginated.

Wraps GET /works. Returns the full pagination envelope rather than a bare array so callers can page through results — the shape matches Community.children and Collection.children.

Examples:

Find stuck deposits

AtlasRb::Work.list(in_progress: true)

Find works whose pipeline gave up

AtlasRb::Work.list(incomplete: true)

Page through all works

AtlasRb::Work.list(page: 2, per_page: 50)

Parameters:

  • in_progress (Boolean, nil) (defaults to: nil)

    when set, filter to Works whose in_progress flag matches. Omit (or pass nil) for "all works".

  • incomplete (Boolean, nil) (defaults to: nil)

    when set, filter to Works whose incomplete flag matches — the staff list of Works whose enrichment pipeline gave up (see mark_incomplete). Independent of in_progress:, and the two combine: in_progress: false, incomplete: true reads as "finished, but degraded".

  • page (Integer, nil) (defaults to: nil)

    1-indexed page number.

  • per_page (Integer, nil) (defaults to: nil)

    page size override.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (AtlasRb::Mash)

    { "works" => [...], "pagination" => {...} }. Each entry in "works" is a Work summary (id, title, description, in_progress, incomplete, incomplete_reason, handle). handle is carried on the summary, not just the detail read, so "which Works never minted?" is answerable from one page rather than a fetch per row.



74
75
76
77
78
79
80
81
82
83
# File 'lib/atlas_rb/work.rb', line 74

def self.list(in_progress: nil, incomplete: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil)
  params = {}
  params[:in_progress] = in_progress unless in_progress.nil?
  params[:incomplete]  = incomplete  unless incomplete.nil?
  params[:page]        = page        if page
  params[:per_page]    = per_page    if per_page
  AtlasRb::Mash.new(JSON.parse(
    connection(params, nuid, on_behalf_of: on_behalf_of).get(ROUTE)&.body
  ))
end

.mark_incomplete(id, reason:, nuid: nil, on_behalf_of: nil) ⇒ Hash

Flag a Work whose enrichment pipeline gave up.

The counterpart to complete, for the other half of the lifecycle: complete says the deposit finished, this says something downstream of it did not. Call it from a work-scoped job's give-up handler — the PDF or media rendition, the derivatives, the full-text extraction — once that job has exhausted its retries. Atlas's GET /works?incomplete=true then lists the Work for staff, and incomplete_bsi on its Solr document lets a result row render a pill without a per-row fetch.

The flag never hides the Work. A record with its file, title and metadata but one missing derivative is degraded, not broken, and stays readable — enrichment does not fail a deposit.

Idempotent on the server; the last reason wins. Clear it with clear_incomplete when a later run of the same job succeeds, which makes the state self-healing.

Examples:

In a give-up handler

AtlasRb::Work.mark_incomplete(work_id, reason: "pdf_rendition_gave_up")

Parameters:

  • id (String)

    the Work ID.

  • reason (String, nil)

    a machine token naming the cause — one per give-up handler, e.g. "pdf_rendition_gave_up", "media_rendition_gave_up", "ingest_gave_up". Atlas stores it as an opaque string and does not validate it against a list, so the vocabulary is the caller's and a new token needs no Atlas release. Map it to display text at the point of use, with a fallback for a token the view has not been taught. A blank reason still sets the flag.

  • nuid (String, nil) (defaults to: nil)

    optional NUID of the acting user.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the updated Work, the same shape find returns, carrying incomplete and incomplete_reason.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



306
307
308
309
310
311
# File 'lib/atlas_rb/work.rb', line 306

def self.mark_incomplete(id, reason:, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .post(ROUTE + id + '/incomplete', JSON.dump(reason: reason))
  ))["work"]
end

.metadata(id, values, nuid: nil, on_behalf_of: nil) ⇒ Hash

Patch individual descriptive-metadata fields without uploading a full MODS document.

Scoped to user-authored descriptive metadata only. Programmatic writes of machine-set Delegate URIs (thumbnails, image derivatives) have their own purpose-specific endpoints — see set_thumbnails and set_image_derivatives.

Examples:

AtlasRb::Work.("w-789", title: "Revised Title")

Parameters:

  • id (String)

    the Work ID.

  • values (Hash)

    field-level metadata updates.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the parsed JSON response.

Raises:



397
398
399
400
401
# File 'lib/atlas_rb/work.rb', line 397

def self.(id, values, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({ metadata: values }, nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id)
  ))
end

.mets(id, nuid: nil, on_behalf_of: nil) ⇒ Hash?

Fetch the Work-level METS structural metadata (page order).

Wraps GET /works/<id>/mets — the JSON projection of the Work's METS document, whose physical structMap is the preservation record of page order. The page sequence surfaces under "mets" => "pages" (one entry per page: noid / order / label). Atlas builds the document when the Work is completed (complete) and rebuilds it on page changes thereafter, so a Work that has never been completed has no METS yet — Atlas answers 404 and this binding returns nil (matching User.find_by_nuid's missing-resource convention).

For runtime page listing (e.g. manifest assembly) prefer file_sets, which needs no completion and carries each page's assets; this read is the preservation-record view.

Examples:

AtlasRb::Work.mets("w-789").mets.pages.map(&:order)
# => [1, 2, 3]

Parameters:

  • id (String)

    the Work ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash, nil)

    the "work" object, already unwrapped: { "id", "mets" => { "created_at_iso", "agent", "files", "structure_label", "pages" => [...] } } — or nil when the Work has no METS yet (never completed) or does not exist.



680
681
682
683
684
685
# File 'lib/atlas_rb/work.rb', line 680

def self.mets(id, nuid: nil, on_behalf_of: nil)
  response = connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/mets')
  return nil if response.status == 404

  AtlasRb::Mash.new(JSON.parse(response.body))["work"]
end

.mods(id, kind = nil, nuid: nil, on_behalf_of: nil) ⇒ String

Fetch the Work's MODS representation in the requested format.

Examples:

AtlasRb::Work.mods("w-789", "html")

Parameters:

  • id (String)

    the Work ID.

  • kind (String, nil) (defaults to: nil)

    one of "json" (default), "html", or "xml".

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (String)

    the raw response body in the requested format.



702
703
704
705
706
707
# File 'lib/atlas_rb/work.rb', line 702

def self.mods(id, kind = nil, nuid: nil, on_behalf_of: nil)
  # json default, html, xml
  connection({}, nuid, on_behalf_of: on_behalf_of).get(
    ROUTE + id + '/mods' + (kind.to_s.empty? ? '' : ".#{kind}")
    )&.body
end

.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil) ⇒ Array<String>

Remove a linked membership: drop a Work from an additional Collection.

Wraps DELETE /works/<id>/linked_members/<collection_id> — the Collection is passed as a path segment, not a body. This removes the Collection from the Work's a_linked_member_of; the structural parent (a_member_of) is untouched. Atlas enforces the same two-sided authorization as add_linked_member. Removing a link that does not exist is a server-side concern; this binding simply forwards the call.

Examples:

AtlasRb::Work.remove_linked_member("w-789", "col-456")
# => []

Parameters:

  • work_id (String)

    the Work ID.

  • collection_id (String)

    the linked Collection to remove.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Array<String>)

    the Work's remaining linked Collection noids after the removal (possibly empty).

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::LinkedMemberError)

    if Atlas rejects the removal on structural grounds (HTTP 422). The envelope's error code is exposed as #code.

  • (AtlasRb::ForbiddenError)

    if Atlas refuses the removal on authorization grounds (HTTP 403).

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



816
817
818
819
820
821
# File 'lib/atlas_rb/work.rb', line 816

def self.remove_linked_member(work_id, collection_id, nuid: nil, on_behalf_of: nil)
  write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .delete(ROUTE + work_id + '/linked_members/' + collection_id)
  )
end

.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil) ⇒ Hash

Move a Work to a different parent Collection.

Wraps PATCH /works/<id>/parent with a parent_id of the new Collection. This changes the Work's single structural home (a_member_of) — distinct from add_linked_member, which adds an additional linked membership without moving the Work. Atlas re-parents the Work and synchronously updates its ancestry index; the structural rules (type, cycle, tombstone guards) are enforced server-side and surface as a 422.

Note: like create, the destination here is a Collection, but the underlying request still uses the shared parent_id body key (not collection_id) — every re-parent endpoint posts { parent_id }.

Examples:

AtlasRb::Work.reparent("w-789", "col-999")

Parameters:

  • id (String)

    the Work ID to move.

  • new_collection_id (String)

    the destination Collection ID.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the updated "work" object, already unwrapped — the same shape find returns, reflecting the new a_member_of.

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::ReparentError)

    if Atlas rejects the move on structural grounds (HTTP 422 — cycle, invalid_parent_type, tombstoned_node, tombstoned_parent, parent_required, parent_not_found). The envelope's error code is exposed as #code.

  • (AtlasRb::ForbiddenError)

    if Atlas refuses the move on authorization grounds (HTTP 403).

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



189
190
191
192
193
194
# File 'lib/atlas_rb/work.rb', line 189

def self.reparent(id, new_collection_id, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({ parent_id: new_collection_id }, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/parent')
  ))["work"]
end

.set_derivative_permissions(id, policy:, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Replace a Work's per-asset derivative-visibility policy.

Sets which read groups may fetch each of the Work's downloadable renditions, reusing the resource read-group vocabulary ("public", Grouper group names, [] = private). Two media families:

  • the image ladder small / medium / large / service (deep-zoom) / master (the original image), and
  • independent media audio / video / pdf.

Unlike set_image_derivatives (which upserts URIs) this is a whole-object REPLACE: the map you pass is the complete policy. Within the image ladder omitted tiers inherit by cascade (an absent tier inherits the next lower-resolution tier; small inherits the Work's own visibility). Independent media do NOT cascade — an absent audio/video/pdf key rides the Work. Pass a tier as [] to make it private.

Atlas enforces: a tier may not be more visible than the Work, and — within the image ladder — visibility must narrow as resolution grows (masterservicelargemediumsmall; independent media impose no ordering). The gate is advisory — it surfaces on assets as gated / permission for BOTH Delegate (image tier) and Blob (master / pdf / audio / video, classified by media type) entries, for the display layer (Cerberus / the IIIF auth service; Cerberus's download :read check) to enforce.

Examples:

AtlasRb::Work.set_derivative_permissions(
  "w-789",
  policy: { small:   ["public"],
            large:   ["northeastern:drs:repository:archives"],
            service: ["northeastern:drs:repository:archives"] }
)

Parameters:

  • id (String)

    the Work ID.

  • policy (Hash)

    tier => Array(read groups), e.g. { large: ["northeastern:drs:repository:archives"], master: [...] }. Keys may be strings or symbols; recognized keys are small / medium / large / service / master / audio / video / pdf.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional On-Behalf-Of NUID.

Returns:

  • (AtlasRb::Mash)

    the updated Work; the stored map echoes back under derivative_permissions.

Raises:



536
537
538
539
540
541
# File 'lib/atlas_rb/work.rb', line 536

def self.set_derivative_permissions(id, policy:, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/derivative_permissions', JSON.dump(policy))
  ))
end

.set_full_text(id, text:, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Store a Work's derived full-document text for search indexing.

Purpose-specific PATCH in the same "machine-set derived metadata" family as set_thumbnails / set_image_derivatives. Hand Atlas the Work-level aggregate of the extracted body text (the concatenation of the Work's content FileSets' text); Atlas stores it as the Work's derived full_text and its FullTextIndexer projects it onto the Work's Solr doc (all_text_timv) for body-text search + the "Full Text Match" snippet.

Distinct from metadata — this is a machine-extracted search aid (pdftotext / Tika in a Cerberus job), not user-authored descriptive content, and is re-sent on any re-ingest. Empty/blank text clears it.

Examples:

AtlasRb::Work.set_full_text("w-789", text: extracted_pdf_text)

Parameters:

  • id (String)

    the Work ID.

  • text (String)

    the extracted plain text (Work-level aggregate).

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (AtlasRb::Mash)

    the parsed JSON response (the Work; the stored text is not echoed back — it's read only through Solr).

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



576
577
578
579
580
581
# File 'lib/atlas_rb/work.rb', line 576

def self.set_full_text(id, text:, nuid: nil, on_behalf_of: nil)
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/full_text', JSON.dump(text: text))
  ))
end

.set_image_derivatives(id, small: nil, medium: nil, large: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Attach the three image-derivative Delegate URIs to a Work.

Sibling of set_thumbnails for the small_image / medium_image / large_image Delegate roles. Atlas dispatches each URI to its matching role via DelegateUpdater. The resulting Delegates are downloadable and surface through assets for the downloads UI. Missing keys are left untouched server-side; only the URIs you pass are upserted.

Examples:

AtlasRb::Work.set_image_derivatives(
  "w-789",
  small:  "https://iiif.example.edu/iiif/3/abc.jp2/full/800,/0/default.jpg",
  medium: "https://iiif.example.edu/iiif/3/abc.jp2/full/1600,/0/default.jpg",
  large:  "https://iiif.example.edu/iiif/3/abc.jp2/full/full/0/default.jpg"
)

Parameters:

  • id (String)

    the Work ID.

  • small (String, nil) (defaults to: nil)

    IIIF URI for the small derivative.

  • medium (String, nil) (defaults to: nil)

    IIIF URI for the medium derivative.

  • large (String, nil) (defaults to: nil)

    IIIF URI for the large derivative.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

Returns:

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



475
476
477
478
479
480
481
# File 'lib/atlas_rb/work.rb', line 475

def self.set_image_derivatives(id, small: nil, medium: nil, large: nil, nuid: nil, on_behalf_of: nil)
  body = { small: small, medium: medium, large: large }.compact
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/image_derivatives', JSON.dump(body))
  ))
end

.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil) ⇒ AtlasRb::Mash

Attach the three thumbnail/preview Delegate URIs to a Work.

Purpose-specific PATCH for the thumbnail_image / thumbnail_image_2x / preview_image Delegate roles. Atlas dispatches each URI to its matching role via DelegateUpdater. Distinct from metadata — these are machine-set IIIF URIs, not user-authored descriptive content. Missing keys are left untouched server-side; only the URIs you pass are upserted.

Examples:

AtlasRb::Work.set_thumbnails(
  "w-789",
  thumbnail:    "https://iiif.example.edu/iiif/3/abc.jp2/full/!85,85/0/default.jpg",
  thumbnail_2x: "https://iiif.example.edu/iiif/3/abc.jp2/full/!170,170/0/default.jpg",
  preview:      "https://iiif.example.edu/iiif/3/abc.jp2/full/500,/0/default.jpg"
)

Parameters:

  • id (String)

    the Work ID.

  • thumbnail (String, nil) (defaults to: nil)

    IIIF URI for the ~85² thumbnail.

  • thumbnail_2x (String, nil) (defaults to: nil)

    IIIF URI for the ~170² 2x thumbnail.

  • preview (String, nil) (defaults to: nil)

    IIIF URI for the ~500w preview image.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

Returns:

Raises:

  • (AtlasRb::StaleResourceError)

    if Atlas reports an optimistic-lock conflict that exhausted its internal retry budget (HTTP 409 with error: "stale_resource").

  • (AtlasRb::NotFoundError)

    if Atlas answers 404 — the id names no such resource, so the write did not happen.

  • (AtlasRb::ResourceError)

    on any other non-2xx, carrying Atlas's status and body.



435
436
437
438
439
440
441
# File 'lib/atlas_rb/work.rb', line 435

def self.set_thumbnails(id, thumbnail: nil, thumbnail_2x: nil, preview: nil, nuid: nil, on_behalf_of: nil)
  body = { thumbnail: thumbnail, thumbnail_2x: thumbnail_2x, preview: preview }.compact
  AtlasRb::Mash.new(write_resource(
    connection({}, nuid, on_behalf_of: on_behalf_of)
      .patch(ROUTE + id + '/thumbnails', JSON.dump(body))
  ))
end

.tombstone(id, nuid: nil, on_behalf_of: nil) ⇒ Faraday::Response

Tombstone (withdraw) a Work.

The Work remains in Atlas storage along with its FileSets and Blobs, but is marked as withdrawn: search and show pages return a withdrawn stub for every user. Unlike Communities and Collections, Works are always tombstoneable regardless of how many files they hold — the FileSets and Blobs ride along.

Examples:

AtlasRb::Work.tombstone("w-789", nuid: "000000002")

Parameters:

  • id (String)

    the Work ID.

  • nuid (String) (defaults to: nil)

    the acting user's NUID, stamped on the resource as tombstoned_by for audit purposes.

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Faraday::Response)

    the raw response.



214
215
216
# File 'lib/atlas_rb/work.rb', line 214

def self.tombstone(id, nuid: nil, on_behalf_of: nil)
  connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/tombstone')
end

.update(id, xml_path, nuid: nil, on_behalf_of: nil) ⇒ Hash

Replace a Work's metadata by uploading a MODS XML document.

Examples:

AtlasRb::Work.update("w-789", "/tmp/work-mods.xml")

Parameters:

  • id (String)

    the Work ID.

  • xml_path (String)

    path to a MODS XML file on disk.

  • nuid (String, nil) (defaults to: nil)

    optional acting user's NUID. On the relay-signing path it is signed into the assertion sub; on the BYO-JWT (ATLAS_JWT) path it is ignored (identity lives in the token).

  • on_behalf_of (String, nil) (defaults to: nil)

    optional NUID for the On-Behalf-Of header. Falls through to AtlasRb.config.default_on_behalf_of when omitted.

Returns:

  • (Hash)

    the parsed JSON response from the patch.

Raises:



364
365
366
367
368
369
370
371
# File 'lib/atlas_rb/work.rb', line 364

def self.update(id, xml_path, nuid: nil, on_behalf_of: nil)
  payload = { binary: Faraday::Multipart::FilePart.new(File.open(xml_path),
                                                       "application/xml",
                                                       File.basename(xml_path)) }
  AtlasRb::Mash.new(write_resource(
    multipart(nuid, on_behalf_of: on_behalf_of).patch(ROUTE + id, payload)
  ))
end