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:



754
755
756
757
758
759
# File 'lib/atlas_rb/work.rb', line 754

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.



589
590
591
592
593
# File 'lib/atlas_rb/work.rb', line 589

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.



882
883
884
885
886
887
# File 'lib/atlas_rb/work.rb', line 882

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.



840
841
842
843
844
# File 'lib/atlas_rb/work.rb', line 840

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.



319
320
321
322
323
324
# File 'lib/atlas_rb/work.rb', line 319

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 simply 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.

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").



240
241
242
# File 'lib/atlas_rb/work.rb', line 240

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:



132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/atlas_rb/work.rb', line 132

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:



919
920
921
922
923
924
# File 'lib/atlas_rb/work.rb', line 919

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" => [...] }.



624
625
626
627
628
# File 'lib/atlas_rb/work.rb', line 624

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.



712
713
714
715
716
# File 'lib/atlas_rb/work.rb', line 712

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).



71
72
73
74
75
76
77
78
79
80
# File 'lib/atlas_rb/work.rb', line 71

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.



286
287
288
289
290
291
# File 'lib/atlas_rb/work.rb', line 286

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:



377
378
379
380
381
# File 'lib/atlas_rb/work.rb', line 377

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.



660
661
662
663
664
665
# File 'lib/atlas_rb/work.rb', line 660

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.



682
683
684
685
686
687
# File 'lib/atlas_rb/work.rb', line 682

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.



796
797
798
799
800
801
# File 'lib/atlas_rb/work.rb', line 796

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.



186
187
188
189
190
191
# File 'lib/atlas_rb/work.rb', line 186

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:



516
517
518
519
520
521
# File 'lib/atlas_rb/work.rb', line 516

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.



556
557
558
559
560
561
# File 'lib/atlas_rb/work.rb', line 556

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.



455
456
457
458
459
460
461
# File 'lib/atlas_rb/work.rb', line 455

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.



415
416
417
418
419
420
421
# File 'lib/atlas_rb/work.rb', line 415

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.



211
212
213
# File 'lib/atlas_rb/work.rb', line 211

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:



344
345
346
347
348
349
350
351
# File 'lib/atlas_rb/work.rb', line 344

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