@arsedizioni/ars-utils 22.5.12 → 22.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { signal, computed, inject, Service, DestroyRef, Injector } from '@angular/core';
3
- import { HttpErrorResponse, HttpClient, HttpHeaders } from '@angular/common/http';
4
- import { BroadcastService, SystemUtils, SplashService } from '@arsedizioni/ars-utils/core';
5
- import { catchError, throwError, of, EMPTY } from 'rxjs';
3
+ import { HttpResponse, HttpClient } from '@angular/common/http';
4
+ import { BroadcastService, SplashService, SystemUtils } from '@arsedizioni/ars-utils/core';
5
+ import { retry, timer, throwError, tap, catchError, EMPTY, finalize, map, of } from 'rxjs';
6
6
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
7
- import { catchError as catchError$1, map, finalize } from 'rxjs/operators';
7
+ import { catchError as catchError$1 } from 'rxjs/operators';
8
8
 
9
9
  const ClipperMessages = {
10
10
  /**
@@ -12,6 +12,17 @@ const ClipperMessages = {
12
12
  */
13
13
  // Error
14
14
  ERROR: '§clipper-error',
15
+ /**
16
+ * The session is gone: a 401 on a request that was not a login attempt.
17
+ * Emitted once per dead session by `clipperAuthInterceptor`, which re-arms on the first
18
+ * good response.
19
+ */
20
+ ERROR_401: '§clipper-error-401',
21
+ /**
22
+ * The API is not answering: the interceptor has already sent the request three times over
23
+ * ten seconds. Emitted once, and re-armed on the first good response.
24
+ */
25
+ ERROR_UNAVAILABLE: '§clipper-error-unavailable',
15
26
  SUCCESS: '§clipper-success',
16
27
  SUCCESS_TOAST: '§clipper-success-toast',
17
28
  // Login
@@ -2098,14 +2109,147 @@ class ClipperSearchUtils {
2098
2109
  }
2099
2110
  }
2100
2111
 
2101
- /** Minimum milliseconds between consecutive error broadcasts (debounce guard). */
2112
+ /**
2113
+ * HTML text helpers with no dependencies of their own.
2114
+ *
2115
+ * Not exported from `public_api`: it is an internal detail of this entry point. `escapeHtml` lives
2116
+ * in a file of its own, and not next to the service that needs it, because `ClipperCoreService`
2117
+ * has to escape and everything else in its neighbourhood imports it back -- a helper that turns
2118
+ * text into text has no business being in that cycle.
2119
+ */
2120
+ function escapeHtml(value) {
2121
+ if (value === undefined || value === null)
2122
+ return undefined;
2123
+ return String(value)
2124
+ .replaceAll('&', '&')
2125
+ .replaceAll('<', '&lt;')
2126
+ .replaceAll('>', '&gt;')
2127
+ .replaceAll('"', '&quot;')
2128
+ .replaceAll("'", "&#39;");
2129
+ }
2130
+
2131
+ /** How many more times a request is sent when the API is not there. */
2132
+ const UNAVAILABLE_RETRIES = 2;
2133
+ /** How long to wait before each of those retries, in milliseconds. */
2134
+ const UNAVAILABLE_RETRY_DELAY = 5000;
2135
+ /** The statuses a server that is being replaced answers with, plus the one it cannot answer at all. */
2136
+ const UNAVAILABLE_STATUSES = [0, 500, 502, 503, 504];
2137
+ /** Minimum milliseconds between consecutive generic error broadcasts. */
2102
2138
  const ERROR_DEBOUNCE_MS = 5000;
2139
+ /**
2140
+ * Returns whether the failure means the API is not there, rather than the request being wrong.
2141
+ *
2142
+ * The discriminator is the body, not the status. An error raised INSIDE the application arrives
2143
+ * through `ApiExceptionHandler` wrapped in the usual `ApiResult` envelope, so it has been seen,
2144
+ * parsed and acted upon: replaying it would run whatever it already ran a second time. A server
2145
+ * that is being replaced answers with IIS's own HTML, or with nothing at all — no envelope — and
2146
+ * that request never reached any handler. Only the second kind is retried, which is what makes it
2147
+ * safe to retry a POST.
2148
+ * @param error - The raw HTTP error object.
2149
+ * @returns True when the request is worth sending again.
2150
+ */
2151
+ function isServiceUnavailable(error) {
2152
+ if (!UNAVAILABLE_STATUSES.includes(parseInt(error?.status ?? '0')))
2153
+ return false;
2154
+ const body = error?.error;
2155
+ const isApiEnvelope = !!body
2156
+ && typeof body === 'object'
2157
+ && !(body instanceof Blob)
2158
+ && ('success' in body || 'problem' in body);
2159
+ return !isApiEnvelope;
2160
+ }
2161
+ /**
2162
+ * Returns whether the URL is an authentication endpoint where a 401 is a normal outcome — wrong
2163
+ * credentials, a pending code challenge, a rejected assertion — rather than an expired session.
2164
+ *
2165
+ * The list covers the whole Clipper 7.5 sign-in surface, including the ways in that this library
2166
+ * does not itself offer: a host application registers one interceptor for every call it makes to
2167
+ * the API, its own passkey and recovery flows included.
2168
+ * @param url - The request URL.
2169
+ * @returns True when the URL is a login/confirm/otp/oauth/passkey endpoint.
2170
+ */
2171
+ function isAuthEndpoint(url) {
2172
+ return url.includes('/session/login')
2173
+ || url.includes('/session/confirm')
2174
+ || url.includes('/session/otp')
2175
+ || url.includes('/session/oauth')
2176
+ || url.includes('/passkeys/login')
2177
+ || url.includes('/passkeys/request-options');
2178
+ }
2179
+ /**
2180
+ * Returns whether the failure means the session is gone rather than the request being wrong.
2181
+ * @param errorStatus - The numeric HTTP status code.
2182
+ * @param url - The request URL.
2183
+ * @returns True when the client must be sent back to the sign-in page.
2184
+ */
2185
+ function isSessionExpired(errorStatus, url) {
2186
+ return errorStatus === 401 && !isAuthEndpoint(url);
2187
+ }
2188
+ /**
2189
+ * Resolves a user-facing error message based on the HTTP status code.
2190
+ *
2191
+ * There is no branch for status 0 any more, nor for the 5xx family: a request that got no answer,
2192
+ * or an answer with no `ApiResult` envelope, never reaches here — it is retried and then reported
2193
+ * as `ERROR_UNAVAILABLE`. What is left is the failures the API itself described.
2194
+ *
2195
+ * Whatever the API sends is escaped before anything else happens to it. The dialog that shows this
2196
+ * string binds it with `[innerHTML]` through a pipe that calls `bypassSecurityTrustHtml`, so an
2197
+ * error message echoing back something a user had typed would be rendered as markup rather than
2198
+ * read as text. The paragraph breaks in the default branch are applied AFTER the escape, which is
2199
+ * exactly why they survive it and nothing else does.
2200
+ * @param error - The raw HTTP error object.
2201
+ * @param errorStatus - The numeric HTTP status code.
2202
+ * @returns A localised error message, safe to render as HTML.
2203
+ */
2204
+ function resolveErrorMessage(error, errorStatus) {
2205
+ const message = escapeHtml(error?.error?.message ?? error?.message);
2206
+ switch (errorStatus) {
2207
+ case 401:
2208
+ // A 401 that reaches here comes from a login attempt: wrong credentials, a rejected
2209
+ // assertion, or an account whose access was revoked.
2210
+ return message ?? 'Credenziali non valide o accesso non abilitato.';
2211
+ case 403:
2212
+ return 'Non hai i permessi necessari per eseguire l\'operazione richiesta.';
2213
+ case 429:
2214
+ return message ?? 'Troppe richieste consecutive. Aspetta qualche secondo.';
2215
+ default:
2216
+ return (message ?? 'Impossibile eseguire l\'operazione richiesta.')
2217
+ .replaceAll('\r\n', '</p><p>');
2218
+ }
2219
+ }
2103
2220
  /**
2104
2221
  * Builds the Clipper auth HTTP interceptor.
2105
2222
  *
2106
- * Takes its two inputs — the Clipper base URI and the service flags — directly
2107
- * as arguments (captured in the closure), so it never injects ClipperService:
2108
- * registering it does not pull the full Clipper service into the eager bundle.
2223
+ * Attaches the credentials and the service-worker bypass header to every request aimed at the
2224
+ * Clipper API, and turns HTTP failures into broadcast messages — no UI dependency here.
2225
+ *
2226
+ * Takes the Clipper base URI and the service flags directly as arguments, captured in the closure,
2227
+ * so it never injects `ClipperService`: registering it does not pull the whole Clipper service into
2228
+ * the eager bundle. The debounce and the two notification latches live in that same closure, so two
2229
+ * applications registering two interceptors never share them.
2230
+ *
2231
+ * ## What the statuses mean
2232
+ *
2233
+ * Aligned with the Clipper 7.5 API on 2026-08-25. The meaning of the status codes changed with the
2234
+ * API: failures used to arrive as `200 OK` with `success: false`, and this interceptor had to guess.
2235
+ * Now the status is the truth — a 401 on an already-authenticated request means the session is
2236
+ * gone, a 403 means the permission is missing, and a 5xx means the server broke, which is not the
2237
+ * same thing at all and must not sign the user out.
2238
+ *
2239
+ * A dead session is broadcast as `ClipperMessages.ERROR_401`, for the host application to handle
2240
+ * centrally; every other failure stays a `ClipperMessages.ERROR` carrying a message. The two are
2241
+ * separate because they call for opposite things: one returns to the sign-in page, the other shows
2242
+ * a dialog and leaves the user where they are.
2243
+ *
2244
+ * Between them sits the third case, which is neither: the API is being replaced. A deployment
2245
+ * answers with a 5xx that has no `ApiResult` envelope, or does not answer at all, for the few
2246
+ * seconds the process takes to come back. That is not worth a dialog and not worth a redirect
2247
+ * either, so the request is simply sent again — twice, five seconds apart — and only if the third
2248
+ * attempt fails too does it become `ClipperMessages.ERROR_UNAVAILABLE`.
2249
+ *
2250
+ * `ClipperServiceFlags.NotifySystemErrors` still governs one narrow case, and only that one: a 5xx
2251
+ * that DOES carry an envelope, i.e. a server error the API itself described. Without the flag it is
2252
+ * swallowed, as it always was in this library.
2109
2253
  *
2110
2254
  * Register it with `withInterceptors`:
2111
2255
  * @example
@@ -2118,48 +2262,35 @@ const ERROR_DEBOUNCE_MS = 5000;
2118
2262
  * @returns An `HttpInterceptorFn` ready to register.
2119
2263
  */
2120
2264
  function clipperAuthInterceptor(serviceUri, flags = ClipperServiceFlags.None) {
2121
- // Debounce state, persisted across requests via the factory closure.
2265
+ /** Timestamp of the last generic error shown, used to debounce repeated error messages. */
2122
2266
  let lastErrorTime = -1;
2123
2267
  /**
2124
- * Broadcasts a user-friendly message for a Clipper HTTP error, debounced.
2268
+ * Whether the unavailable-API notification has already been sent. A deployment fails every
2269
+ * request in flight, and one notification is enough. Re-armed on the next successful response.
2270
+ */
2271
+ let unavailableNotified = false;
2272
+ /**
2273
+ * Whether the session-expiry notification has already been sent. Prevents a storm of dialogs when
2274
+ * several in-flight requests fail at once; re-armed on the next successful response.
2275
+ */
2276
+ let sessionExpiredNotified = false;
2277
+ /**
2278
+ * Broadcasts a generic, user-facing error for a Clipper HTTP failure, debounced.
2125
2279
  * @param error - The raw error value thrown by the HTTP layer.
2280
+ * @param errorStatus - The numeric HTTP status code.
2126
2281
  * @param broadcastService - The broadcast service for the current request.
2127
2282
  * @returns void
2128
2283
  */
2129
- function handleError(error, broadcastService) {
2130
- if (!(error instanceof HttpErrorResponse))
2131
- return;
2132
- // Reject only errors that explicitly belong to a different service; a missing
2133
- // url (network failure on a Clipper request) is allowed through.
2134
- if (error.url && !error.url.startsWith(serviceUri))
2135
- return;
2136
- const errorStatus = error.status;
2137
- const shouldNotify = errorStatus === 0 ||
2138
- (errorStatus > 0 && errorStatus < 500) ||
2139
- (flags & ClipperServiceFlags.NotifySystemErrors) > 0;
2140
- if (!shouldNotify)
2284
+ function notifyError(error, errorStatus, broadcastService) {
2285
+ // A 5xx the API described itself is the one case the flag still governs.
2286
+ if (errorStatus >= 500 && (flags & ClipperServiceFlags.NotifySystemErrors) === 0)
2141
2287
  return;
2142
2288
  const now = Date.now();
2143
2289
  if (now - lastErrorTime <= ERROR_DEBOUNCE_MS)
2144
2290
  return;
2145
2291
  lastErrorTime = now;
2146
- let message;
2147
- switch (errorStatus) {
2148
- case 0:
2149
- message = "In questo momento Clipper non è disponibile. Riprova tra qualche minuto.";
2150
- break;
2151
- case 403:
2152
- message = "Non hai i permessi necessari per eseguire l'operazione richiesta.";
2153
- break;
2154
- default:
2155
- message = (error.error?.['message'] ??
2156
- error.message ??
2157
- "Impossibile eseguire l'operazione richiesta.").replaceAll('\r\n', '</p><p>');
2158
- break;
2159
- }
2160
2292
  broadcastService.sendMessage(ClipperMessages.ERROR, {
2161
- invalidateSession: errorStatus === 405 || errorStatus === 410,
2162
- message,
2293
+ message: resolveErrorMessage(error, errorStatus),
2163
2294
  title: "Errore in Clipper",
2164
2295
  errorStatus,
2165
2296
  service: serviceUri
@@ -2178,848 +2309,925 @@ function clipperAuthInterceptor(serviceUri, flags = ClipperServiceFlags.None) {
2178
2309
  'X-Client-Id': sessionStorage.getItem('clipper_client_id') ?? ''
2179
2310
  }
2180
2311
  });
2181
- return next(authenticatedRequest).pipe(catchError((error) => {
2182
- handleError(error, broadcastService);
2183
- return throwError(() => error);
2312
+ return next(authenticatedRequest).pipe(
2313
+ // An API that is being replaced is not an error to show, it is a wait: two more attempts,
2314
+ // five seconds apart. Anything else is rethrown on the spot, so a request that genuinely
2315
+ // failed still fails immediately.
2316
+ retry({
2317
+ count: UNAVAILABLE_RETRIES,
2318
+ delay: error => isServiceUnavailable(error)
2319
+ ? timer(UNAVAILABLE_RETRY_DELAY)
2320
+ : throwError(() => error)
2321
+ }), tap(event => {
2322
+ // A successful response means the session is valid again, and the API is back: re-arm both
2323
+ // notifiers.
2324
+ if (event instanceof HttpResponse) {
2325
+ sessionExpiredNotified = false;
2326
+ unavailableNotified = false;
2327
+ }
2328
+ }), catchError((error) => {
2329
+ // Which request this was is read from the request, not from `error.url`: a failure with no
2330
+ // response — the browser refusing the connection while the API restarts — carries no URL
2331
+ // at all. Everything that gets here is a Clipper request anyway: the others returned
2332
+ // before the pipe was built.
2333
+ const errorStatus = parseInt(error?.status ?? '0');
2334
+ if (isServiceUnavailable(error)) {
2335
+ // Three attempts over ten seconds and the API is still not answering. There is nothing
2336
+ // left to retry, so the host application is told once, however many requests were in
2337
+ // flight.
2338
+ if (!unavailableNotified) {
2339
+ unavailableNotified = true;
2340
+ broadcastService.sendMessage(ClipperMessages.ERROR_UNAVAILABLE);
2341
+ }
2342
+ }
2343
+ else if (isSessionExpired(errorStatus, authenticatedRequest.url)) {
2344
+ // Expired or invalidated session on an authenticated request. Notified once: several
2345
+ // in-flight requests failing together is how a single expiry becomes a wall of dialogs.
2346
+ if (!sessionExpiredNotified) {
2347
+ sessionExpiredNotified = true;
2348
+ broadcastService.sendMessage(ClipperMessages.ERROR_401);
2349
+ }
2350
+ }
2351
+ else {
2352
+ // Any other failure, including a 401 from a login attempt.
2353
+ notifyError(error, errorStatus, broadcastService);
2354
+ }
2355
+ // Consumed, not rethrown — as in Clipper 7.5. The failure has been reported on the bus, and
2356
+ // rethrowing it a second time only gave every subscriber its own chance to report it again.
2357
+ // A caller that must know how a request ended reads `ApiResult.success`; one that subscribes
2358
+ // to a failing request now simply completes without emitting.
2359
+ return EMPTY;
2184
2360
  }));
2185
2361
  };
2186
2362
  }
2187
2363
 
2188
2364
  /**
2189
- * Document search, references, export and metadata, plus the dashboard counters,
2190
- * taxonomy/topics/tags lookups, the working-documents "bag" and saved searches.
2365
+ * Authentication: password login, e-mailed code confirmation, logout and session restore.
2366
+ * The login context, the channels and the logged-in flag live in `ClipperCoreService`.
2367
+ *
2368
+ * Aligned with the Clipper 7.5 API on 2026-08-25. Everything here now goes through `/session/*`
2369
+ * and an HttpOnly cookie: there is no bearer token, no `remember` credential the API can replay,
2370
+ * and no OAuth token acquired by the caller — the external-provider flow is a full-page redirect
2371
+ * handled server-side, and all that reaches this service is the `/session/me` call on the way back.
2372
+ *
2373
+ * This is a deliberately reduced subset of what the Clipper application itself does. Passkeys and
2374
+ * the passwordless recovery code (`/session/otp`) are not here: a library whose job is showing
2375
+ * documents signs in with a password, and an application that offers the other ways in owns them.
2191
2376
  */
2192
- class ClipperDocumentsService {
2377
+ class ClipperLoginService {
2193
2378
  constructor() {
2194
2379
  this.httpClient = inject(HttpClient);
2195
2380
  this.broadcastService = inject(BroadcastService);
2381
+ this.splashService = inject(SplashService);
2196
2382
  this.core = inject(ClipperCoreService);
2383
+ /** Whether {@link initialize} has already run. */
2384
+ this.initialized = false;
2197
2385
  }
2198
- /////
2199
- // DOCUMENTS
2200
- /////
2386
+ ////
2387
+ // BOOTSTRAP
2388
+ ////
2201
2389
  /**
2202
- * Queries documents matching the given search parameters.
2203
- * @param params - The document search parameters.
2204
- * @returns An observable emitting the API result wrapping the search result.
2390
+ * Validates a context that survived a page refresh before trusting it.
2391
+ *
2392
+ * Called by `ClipperService.initialize`, right after `ClipperCoreService.initialize`, so a
2393
+ * consumer gets the restore for free and never has to know this method exists. The auth cookie is
2394
+ * HttpOnly and may have expired while the stored context lived on, so a stored context only ever
2395
+ * means "there WAS a session" — this is what turns it into "there is one".
2396
+ *
2397
+ * A host application that drives its own return from an external provider, or that starts on its
2398
+ * own sign-in page, should call `core.setLoggedIn(false)` before bootstrapping rather than let
2399
+ * this race its own restore.
2400
+ * @returns void
2205
2401
  */
2206
- query(params) {
2207
- return this.httpClient.post(this.core.serviceUri + '/documents', params);
2402
+ initialize() {
2403
+ if (this.initialized)
2404
+ return;
2405
+ this.initialized = true;
2406
+ if (this.core.loggedIn()) {
2407
+ this.core.loggingIn.set(true);
2408
+ this.restoreSession();
2409
+ }
2208
2410
  }
2209
2411
  /**
2210
- * Retrieves the facets for a document query.
2211
- * @param params - The document search parameters.
2212
- * @returns An observable emitting the API result wrapping the search facets.
2412
+ * Asks `/session/me` whether the restored session is still alive. On success the login is
2413
+ * finalised from the current claims; on a dead session the interceptor's 401 handling takes over.
2414
+ *
2415
+ * Nothing is cleared on failure, and that is deliberate: a failure here is not proof of a dead
2416
+ * session — the API may be unreachable, the proxy may have answered 502, the device may be
2417
+ * offline. Clearing on any of those would sign the user out with a perfectly valid cookie.
2418
+ * @returns void
2213
2419
  */
2214
- queryFacets(params) {
2215
- return this.httpClient.post(this.core.serviceUri + '/documents/facets', params);
2420
+ restoreSession() {
2421
+ this.me()
2422
+ .pipe(finalize(() => this.core.loggingIn.set(false)))
2423
+ .subscribe(r => {
2424
+ if (r?.success) {
2425
+ this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2426
+ }
2427
+ });
2216
2428
  }
2429
+ ////
2430
+ // SESSION
2431
+ ////
2217
2432
  /**
2218
- * Updates the state of one or more documents.
2219
- * @param params - The document state update parameters.
2220
- * @returns An observable emitting the API result wrapping the number of updated documents.
2433
+ * Authenticates the user with email and password.
2434
+ *
2435
+ * Nothing is replayed from a stored credential any more: the former "remember me" token, which
2436
+ * let the API sign a caller in from something kept for sixty days, is gone. A login always starts
2437
+ * from what the user just typed.
2438
+ * @param email - The user's email address.
2439
+ * @param password - The user's password.
2440
+ * @returns An observable emitting the API result wrapping the login result.
2221
2441
  */
2222
- updateState(params) {
2223
- return this.httpClient.post(this.core.serviceUri + '/documents/state/update', params);
2442
+ login(email, password) {
2443
+ this.splashService.setMessage('Accesso in corso...');
2444
+ return this.httpClient
2445
+ .post(this.core.serviceUri + '/session/login', { user: email ?? '', password: password ?? '' })
2446
+ .pipe(catchError(err => throwError(() => err)), map(r => {
2447
+ if (r.success) {
2448
+ if (r.value.requiresMfa) {
2449
+ // Notify the login is pending an e-mailed code.
2450
+ this.broadcastService.sendMessage(ClipperMessages.LOGIN_PENDING, {});
2451
+ }
2452
+ else {
2453
+ this.completeLogin(r.value);
2454
+ }
2455
+ }
2456
+ return r;
2457
+ }));
2224
2458
  }
2225
2459
  /**
2226
- * Exports a single document in PDF format.
2227
- * @param id - The ID of the document to export.
2228
- * @returns An observable emitting the PDF binary content as a blob.
2460
+ * Confirms a pending e-mailed code challenge, which establishes the session.
2461
+ *
2462
+ * The address travels with the code because the API stores each code under the account it was
2463
+ * issued to: a code alone is not redeemable, which is what stops a guessed code from opening
2464
+ * whatever session happens to be pending. It also keeps the code out of the URL, where it used to
2465
+ * sit — `/login/confirm/{code}` put it in the path, and therefore in the server logs and in the
2466
+ * browser history.
2467
+ * @param code - The one-time confirmation code provided to the user.
2468
+ * @param email - The address the code was sent to.
2469
+ * @returns An observable emitting the API result wrapping the login result.
2229
2470
  */
2230
- exportPdf(id) {
2231
- return this.httpClient.get(this.core.serviceUri + '/documents/export/' + id, { responseType: 'blob' });
2471
+ confirm(code, email) {
2472
+ return this.httpClient
2473
+ .post(this.core.serviceUri + '/session/confirm', { email: email, code: code })
2474
+ .pipe(catchError(err => throwError(() => err)), map(r => {
2475
+ if (r.success) {
2476
+ this.completeLogin(r.value);
2477
+ }
2478
+ return r;
2479
+ }));
2232
2480
  }
2233
2481
  /**
2234
- * Exports a document list (query or selected items), or exports deadlines as ICS.
2235
- * @param params - The export parameters.
2236
- * @returns An observable emitting the exported content as a blob.
2482
+ * Logs the user out on the server and clears the local state.
2483
+ * @returns An observable that emits once the logout has been processed.
2237
2484
  */
2238
- export(params) {
2239
- return this.httpClient.post(this.core.serviceUri + '/documents/export', params, { responseType: 'blob' });
2485
+ logout() {
2486
+ return this.httpClient
2487
+ .post(this.core.serviceUri + '/session/logout', {})
2488
+ .pipe(finalize(() => this.core.clear(true)), catchError(() => of({ success: false })));
2240
2489
  }
2241
2490
  /**
2242
- * Sends document links by email.
2243
- * @param params - The send-by-email parameters including recipients and documents.
2244
- * @returns An observable emitting the API result wrapping the number of sent items.
2491
+ * Reads the current session from the server, which resolves it from the auth cookie claims
2492
+ * without touching the database. Used to restore the state after a page refresh instead of
2493
+ * trusting what local storage holds, and to close an external-provider round-trip.
2494
+ * @returns An observable emitting the current session.
2245
2495
  */
2246
- sendTo(params) {
2247
- return this.httpClient.post(this.core.serviceUri + '/documents/send', params);
2496
+ me() {
2497
+ return this.httpClient
2498
+ .get(this.core.serviceUri + '/session/me', { withCredentials: true })
2499
+ .pipe(map(r => {
2500
+ if (r.success) {
2501
+ this.completeLogin(r.value, false);
2502
+ }
2503
+ return r;
2504
+ }));
2248
2505
  }
2249
2506
  /**
2250
- * Retrieves the full document report page.
2251
- * @param id - The ID of the document.
2252
- * @returns An observable emitting the report content as a blob.
2507
+ * Finalises the login flow after an authentication, a code confirmation or a session restore:
2508
+ * writes the context into the shared state, raises the logged-in signal, rebuilds the channel
2509
+ * list and announces `LOGIN_COMPLETED`.
2510
+ *
2511
+ * `loggingIn` is deliberately left alone. Whoever raised it lowers it — {@link restoreSession}
2512
+ * for a refresh, the host application for an external-login round-trip — because lowering it
2513
+ * here would drop the splash the instant the response arrived, before anything was on screen.
2514
+ * @param result - The login result returned by the API.
2515
+ * @param notify - Whether to broadcast `LOGIN_COMPLETED`. Defaults to `true`.
2516
+ * @returns void
2253
2517
  */
2254
- report(id) {
2255
- return this.httpClient.get(this.core.serviceUri + '/documents/report/' + id, { responseType: 'blob' });
2518
+ completeLogin(result, notify = true) {
2519
+ this.core.updateContext(result);
2520
+ this.core.setLoggedIn(true);
2521
+ this.core.initializeChannels();
2522
+ if (notify) {
2523
+ this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2524
+ }
2256
2525
  }
2257
2526
  /**
2258
- * Gets the comment associated with a document.
2259
- * @param id - The ID of the document.
2260
- * @returns An observable emitting the API result wrapping the comment text.
2527
+ * Requests a new one-time password used to authorise a binary download.
2528
+ * @param id - The repository ID of the binary the OTP is generated for.
2529
+ * @returns An observable emitting the API result wrapping the generated OTP info.
2261
2530
  */
2262
- comment(id) {
2263
- return this.httpClient.get(this.core.serviceUri + '/documents/comment/' + id);
2531
+ newOTP(id) {
2532
+ return this.httpClient.get(this.core.serviceUri + '/otp/new/?id=' + id);
2264
2533
  }
2534
+ ////
2535
+ // CONTEXT (re-published from ClipperCoreService for consumer convenience)
2536
+ ////
2265
2537
  /**
2266
- * Gets the info for a document.
2267
- * @param id - The ID of the document.
2268
- * @returns An observable emitting the API result wrapping the document info.
2538
+ * Persists the current login context to `localStorage`.
2539
+ * Delegates to `ClipperCoreService.storeContext()`.
2540
+ * @returns void
2269
2541
  */
2270
- info(id) {
2271
- return this.httpClient.get(this.core.serviceUri + '/documents/info/' + id);
2542
+ storeContext() {
2543
+ this.core.storeContext();
2272
2544
  }
2273
2545
  /**
2274
- * Gets the structure (index) of a document.
2275
- * @param id - The ID of the document.
2276
- * @returns An observable emitting the API result wrapping the document structure.
2546
+ * Clears every trace of the current session on this device: the stored context, the logged-in
2547
+ * signal and the session-storage keys. Does NOT call the server — use {@link logout} for that.
2548
+ * Delegates to `ClipperCoreService.clear()`.
2549
+ * @param clearOAuthToken - Whether to drop the legacy OAuth token too. Defaults to `true`.
2550
+ * @returns void
2277
2551
  */
2278
- index(id) {
2279
- return this.httpClient.get(this.core.serviceUri + '/documents/structure/' + id);
2552
+ clear(clearOAuthToken = true) {
2553
+ this.core.clear(clearOAuthToken);
2554
+ }
2555
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2556
+ static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService }); }
2557
+ }
2558
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService, decorators: [{
2559
+ type: Service
2560
+ }] });
2561
+
2562
+ /**
2563
+ * Shared state and bootstrap for the Clipper application.
2564
+ *
2565
+ * Holds every piece of state used by more than one feature service (service URI,
2566
+ * flags, login context, dashboard, working-documents bag, channels and the various
2567
+ * UI signals) plus the low-level helpers that mutate that state.
2568
+ *
2569
+ * The feature services (`ClipperLoginService`, `ClipperDocumentsService`, ...) inject
2570
+ * this service to read shared state and call shared helpers. `ClipperService` (the
2571
+ * barrel/facade) re-exports the state declared here.
2572
+ */
2573
+ class ClipperCoreService {
2574
+ constructor() {
2575
+ this.httpClient = inject(HttpClient);
2576
+ this.destroyRef = inject(DestroyRef);
2577
+ this.broadcastService = inject(BroadcastService);
2578
+ /** Used to lazily resolve feature services inside `initialize`, avoiding circular DI. */
2579
+ this.injector = inject(Injector);
2580
+ this.broadcastInitialized = false;
2581
+ this._serviceUri = '';
2582
+ this._flags = ClipperServiceFlags.None;
2583
+ /**
2584
+ * Seeded from the stored context, which is the only trace a session leaves on this device: the
2585
+ * auth cookie is HttpOnly and unreadable from here. It means "there WAS a session", never "there
2586
+ * is one" -- `ClipperLoginService.initialize` validates it against `/session/me` before it is
2587
+ * trusted, and a failure there ends as a 401 that clears the session.
2588
+ *
2589
+ * It used to be seeded from a `clipper_oauth_token` session-storage key, which nothing has
2590
+ * written since the provider flow moved server-side: the flag was therefore false at every
2591
+ * bootstrap and the whole restore path could never run.
2592
+ */
2593
+ this._loggedIn = signal(localStorage.getItem('clipper_context') !== null, /* @ts-ignore */
2594
+ ...(ngDevMode ? [{ debugName: "_loggedIn" }] : /* istanbul ignore next */ []));
2595
+ this.loggedIn = this._loggedIn.asReadonly();
2596
+ this.loggingIn = signal(false, /* @ts-ignore */
2597
+ ...(ngDevMode ? [{ debugName: "loggingIn" }] : /* istanbul ignore next */ []));
2598
+ this.snapshot = signal(undefined, /* @ts-ignore */
2599
+ ...(ngDevMode ? [{ debugName: "snapshot" }] : /* istanbul ignore next */ []));
2600
+ this.supportsRS = signal(false, /* @ts-ignore */
2601
+ ...(ngDevMode ? [{ debugName: "supportsRS" }] : /* istanbul ignore next */ []));
2602
+ this.referencesSnapshot = signal(undefined, /* @ts-ignore */
2603
+ ...(ngDevMode ? [{ debugName: "referencesSnapshot" }] : /* istanbul ignore next */ []));
2604
+ this.dashboard = new ClipperDashboard();
2605
+ this.bag = signal([], /* @ts-ignore */
2606
+ ...(ngDevMode ? [{ debugName: "bag" }] : /* istanbul ignore next */ []));
2607
+ this.bagTotal = computed(() => this.bag().length, /* @ts-ignore */
2608
+ ...(ngDevMode ? [{ debugName: "bagTotal" }] : /* istanbul ignore next */ []));
2609
+ this.visible = signal(false, /* @ts-ignore */
2610
+ ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
2611
+ this.availableChannels = signal([], /* @ts-ignore */
2612
+ ...(ngDevMode ? [{ debugName: "availableChannels" }] : /* istanbul ignore next */ []));
2613
+ this.activeChannels = computed(() => {
2614
+ return this.availableChannels()?.filter(x => !x.suspended && !x.disabled && x.active === true);
2615
+ }, /* @ts-ignore */
2616
+ ...(ngDevMode ? [{ debugName: "activeChannels" }] : /* istanbul ignore next */ []));
2617
+ this.allowTags = signal(false, /* @ts-ignore */
2618
+ ...(ngDevMode ? [{ debugName: "allowTags" }] : /* istanbul ignore next */ []));
2619
+ }
2620
+ /** @returns The URI of the Clipper web application, or `undefined` if not set. */
2621
+ get appUri() {
2622
+ return this._appUri;
2623
+ }
2624
+ /** @returns The base URI of the Clipper REST API. */
2625
+ get serviceUri() {
2626
+ return this._serviceUri;
2627
+ }
2628
+ /** @returns The active feature flags. */
2629
+ get flags() {
2630
+ return this._flags;
2280
2631
  }
2281
2632
  /**
2282
- * Gets the last-update metadata for a document.
2283
- * @param id - The ID of the document.
2284
- * @returns An observable emitting the API result wrapping the last update string.
2633
+ * Lazily loads the login context from `localStorage` on first access.
2634
+ * @returns The current login context, or `undefined` if not authenticated.
2285
2635
  */
2286
- metadata(id) {
2287
- return this.httpClient.get(this.core.serviceUri + '/documents/metadata/' + id);
2636
+ get loginInfo() {
2637
+ if (!this._loginInfo) {
2638
+ const loginInfo = localStorage.getItem('clipper_context');
2639
+ if (loginInfo) {
2640
+ try {
2641
+ this._loginInfo = JSON.parse(loginInfo);
2642
+ }
2643
+ catch { }
2644
+ }
2645
+ }
2646
+ return this._loginInfo;
2288
2647
  }
2648
+ ////
2649
+ // SHARED MUTATORS
2650
+ ////
2289
2651
  /**
2290
- * Queries document references, changes or jurisprudence depending on the requested mode.
2291
- * @param params - The references search parameters; `mode` defaults to `ReferencesIn`.
2292
- * @returns An observable emitting the API result wrapping the search result, or `null` for an unsupported mode.
2293
- */
2294
- references(params) {
2295
- let mode = params.mode;
2296
- if (!mode)
2297
- mode = ClipperQueryReferencesMode.ReferencesIn;
2298
- switch (mode) {
2299
- case ClipperQueryReferencesMode.ReferencesIn:
2300
- case ClipperQueryReferencesMode.ReferencesOut:
2301
- params.mode = mode;
2302
- return this.httpClient.post(this.core.serviceUri + '/documents/references', params);
2303
- case ClipperQueryReferencesMode.ChangesIn:
2304
- case ClipperQueryReferencesMode.ChangesOut:
2305
- return this.httpClient.post(this.core.serviceUri + '/documents/changes', params);
2306
- case ClipperQueryReferencesMode.Juris:
2307
- return this.httpClient.post(this.core.serviceUri + '/documents/juris', params);
2308
- default:
2309
- return null;
2310
- }
2311
- }
2312
- /**
2313
- * Retrieves the facets for a document references query.
2314
- * @param params - The references search parameters; `mode` defaults to `ReferencesIn`.
2315
- * @returns An observable emitting the API result wrapping the search facets, or `null` for an unsupported mode.
2652
+ * Ensures a login-info object exists and returns it for further mutation.
2653
+ * Replaces the inline `if (!this._loginInfo) { ... }` guards now that the field
2654
+ * is private to this service.
2655
+ *
2656
+ * Reads through the getter, never the field: the context is loaded from storage lazily, so a
2657
+ * caller that mutates before anyone has read would find the field empty and start from a blank
2658
+ * object, silently dropping a perfectly good stored context.
2659
+ * @returns The existing login-info object, or a freshly created empty one.
2316
2660
  */
2317
- referencesFacets(params) {
2318
- let mode = params.mode ?? ClipperQueryReferencesMode.ReferencesIn;
2319
- switch (mode) {
2320
- case ClipperQueryReferencesMode.ReferencesIn:
2321
- case ClipperQueryReferencesMode.ReferencesOut:
2322
- params.mode = mode;
2323
- return this.httpClient.post(this.core.serviceUri + '/documents/references/facets', params);
2324
- case ClipperQueryReferencesMode.ChangesIn:
2325
- case ClipperQueryReferencesMode.ChangesOut:
2326
- return this.httpClient.post(this.core.serviceUri + '/documents/changes/facets', params);
2327
- case ClipperQueryReferencesMode.Juris:
2328
- return this.httpClient.post(this.core.serviceUri + '/documents/juris/facets', params);
2329
- default: return null;
2661
+ ensureLoginInfo() {
2662
+ if (!this.loginInfo) {
2663
+ this._loginInfo = { context: undefined };
2330
2664
  }
2665
+ return this._loginInfo;
2331
2666
  }
2332
2667
  /**
2333
- * Wraps document rendering to allow token refresh.
2334
- * @returns An observable emitting the API result wrapping a boolean readiness flag.
2335
- */
2336
- preRender() {
2337
- return this.httpClient.get(this.core.serviceUri + '/documents/pre-render?nocache=' + SystemUtils.generateUUID());
2338
- }
2339
- /**
2340
- * Gets the jurisprudence articles for a document query.
2341
- * @param params - The document search parameters.
2342
- * @returns An observable emitting the API result wrapping the search result.
2668
+ * Sets the logged-in state. The backing signal is read-only to consumers, so the
2669
+ * feature services use this method instead.
2670
+ * @param value - The new logged-in state.
2343
2671
  */
2344
- jurisArticles(params) {
2345
- return this.httpClient.post(this.core.serviceUri + '/documents/juris/articles', params);
2672
+ setLoggedIn(value) {
2673
+ this._loggedIn.set(value);
2346
2674
  }
2347
2675
  /**
2348
- * Gets a deadlines snapshot based on the supplied deadlines.
2349
- * @param params - The calendar search parameters.
2350
- * @returns An observable emitting the API result wrapping the calendar snapshot result.
2676
+ * Persists the current login context to `localStorage`, but only when there is a context to
2677
+ * persist. A login-info object with no `context` is not a session: it is the empty shell
2678
+ * `ensureLoginInfo` hands out before anything has been written into it, and storing it would leave
2679
+ * behind a key whose mere presence makes the next bootstrap declare the user logged in.
2351
2680
  */
2352
- deadlinesSnapshot(params) {
2353
- return this.httpClient.post(this.core.serviceUri + '/documents/calendar/snapshot', params);
2681
+ storeContext() {
2682
+ // Through the getter, never the field: the context is loaded from storage lazily, so a caller
2683
+ // that stores before anyone has read would otherwise find the field empty and DELETE a
2684
+ // perfectly good stored context.
2685
+ if (!this.loginInfo?.context) {
2686
+ // Nothing to store. Falling through would call `JSON.stringify(undefined)`, whose result is
2687
+ // the value `undefined`, which local storage writes as the nine-letter STRING "undefined" --
2688
+ // a key that exists, parses to nothing, and makes the next bootstrap believe a session was
2689
+ // left behind here. Removing it says the truth instead.
2690
+ localStorage.removeItem('clipper_context');
2691
+ return;
2692
+ }
2693
+ localStorage.setItem('clipper_context', JSON.stringify(this._loginInfo));
2354
2694
  }
2355
2695
  /**
2356
- * Retrieves the taxonomy.
2357
- * @param params - Optional taxonomy parameters. Defaults to `{ model: 0, countItems: false }`.
2358
- * @returns An observable emitting the API result wrapping the taxonomy folder tree.
2696
+ * Updates the stored login context with the values from a fresh login result.
2697
+ *
2698
+ * Each half is written only when the server actually sent it. An absent `context` or `settings`
2699
+ * means "not carried by this response", never "the user has none": overwriting on absence is what
2700
+ * made a page refresh empty the channel list, because `/session/me` answers without the settings
2701
+ * and this method wrote that emptiness -- through `storeContext`, onto the disk, where it survived
2702
+ * the reload it was born in. An EMPTY array is a different matter and is stored as it comes: that
2703
+ * is the server saying the subscription really is gone.
2704
+ * @param result - The login result containing the new user context and channel settings.
2359
2705
  */
2360
- getTaxonomy(params) {
2361
- return this.httpClient.post(this.core.serviceUri + '/taxonomy', params ?? { model: 0, countItems: false });
2706
+ updateContext(result) {
2707
+ const info = this.ensureLoginInfo();
2708
+ if (result.context) {
2709
+ info.context = result.context;
2710
+ }
2711
+ if (result.settings) {
2712
+ info.channels = result.settings;
2713
+ }
2714
+ this.storeContext();
2362
2715
  }
2363
2716
  /**
2364
- * Retrieves the topics as a flat list.
2365
- * @returns An observable emitting the API result wrapping the list of topics.
2717
+ * Rebuilds the `availableChannels` signal from the current login context.
2366
2718
  */
2367
- getTopics() {
2368
- return this.httpClient.get(this.core.serviceUri + '/topics');
2719
+ initializeChannels() {
2720
+ if (this.loginInfo) {
2721
+ const channels = [];
2722
+ this.loginInfo.channels?.forEach(n => {
2723
+ const channelSubscription = this.loginInfo?.context?.channels?.find(x => x.channel === n.channelId);
2724
+ n.isSuspended = channelSubscription?.isSuspended === true;
2725
+ const channel = ClipperChannels.find(x => x.value === n.channelId);
2726
+ if (channel) {
2727
+ channel.disabled = !n.isActive;
2728
+ channel.suspended = n.isSuspended === true;
2729
+ channel.active = n.isActive === true && n.isEnabled === true;
2730
+ channels.push(channel);
2731
+ }
2732
+ });
2733
+ this.availableChannels.set(channels);
2734
+ }
2369
2735
  }
2370
2736
  /**
2371
- * Retrieves the topics as a tree.
2372
- * @returns An observable emitting the API result wrapping the topics folder tree.
2737
+ * Resets the login state, clears the stored login info, and broadcasts `LOGOUT_COMPLETED`.
2373
2738
  */
2374
- getTopicsAsTree() {
2375
- return this.httpClient.get(this.core.serviceUri + '/topics2');
2739
+ reset() {
2740
+ // Clear login info, in memory AND on disk: `loginInfo` re-reads local storage whenever the
2741
+ // field is empty, so leaving the stored copy behind would resurrect the previous user.
2742
+ this._loginInfo = undefined;
2743
+ localStorage.removeItem('clipper_context');
2744
+ // Logged out
2745
+ this._loggedIn.set(false);
2746
+ // Reset channels
2747
+ this.availableChannels.set([]);
2748
+ // Notify
2749
+ this.broadcastService.sendMessage(ClipperMessages.LOGOUT_COMPLETED);
2376
2750
  }
2377
2751
  /**
2378
- * Retrieves the tags.
2379
- * @returns An observable emitting the API result wrapping the list of tags.
2752
+ * Clears all session-storage authentication keys and resets the login state.
2753
+ * @param clearOAuthToken - When `true`, the OAuth bearer token is also removed.
2380
2754
  */
2381
- getTags() {
2382
- return this.httpClient.get(this.core.serviceUri + '/tags');
2755
+ clear(clearOAuthToken = false) {
2756
+ // Clear local storage
2757
+ sessionStorage.removeItem('clipper_auth');
2758
+ sessionStorage.removeItem('clipper_oauth');
2759
+ if (clearOAuthToken) {
2760
+ sessionStorage.removeItem('clipper_oauth_token');
2761
+ }
2762
+ // Reset login
2763
+ this.reset();
2383
2764
  }
2384
- ///
2385
- // BAG
2386
- ///
2765
+ ////
2766
+ // BOOTSTRAP
2767
+ ////
2387
2768
  /**
2388
- * Loads the working documents and populates the shared bag.
2389
- * @returns The subscription to the working-documents request.
2769
+ * Initialises the service with the API base URI, optional app URI, and feature flags.
2770
+ * @param serviceUri - The base URI of the Clipper REST API.
2771
+ * @param appUri - Optional URI of the Clipper web application (used to build document links).
2772
+ * @param flags - Feature flags that control service behaviour. Defaults to `ClipperServiceFlags.None`.
2390
2773
  */
2391
- loadBag() {
2392
- return this.httpClient
2393
- .get(this.core.serviceUri + '/documents/working')
2394
- .subscribe(r => {
2395
- if (!r.success) {
2396
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
2397
- }
2398
- else {
2399
- if (r.value) {
2400
- const items = [];
2401
- r.value.forEach(n => {
2402
- if (n.documentDescriptor) {
2403
- const documentInfo = JSON.parse(n.documentDescriptor);
2404
- if (documentInfo) {
2405
- if (!n.documentId) {
2406
- n.documentId = documentInfo.DocumentId || documentInfo.Id;
2774
+ initialize(serviceUri, appUri, flags = ClipperServiceFlags.None) {
2775
+ // Create unique client and machine id
2776
+ if (!sessionStorage.getItem('clipper_client_id')) {
2777
+ sessionStorage.setItem('clipper_client_id', (flags && ClipperServiceFlags.Embedded) > 0
2778
+ ? 'embedded'
2779
+ : SystemUtils.generateUUID());
2780
+ }
2781
+ // Initialize
2782
+ this._serviceUri = serviceUri;
2783
+ this._appUri = appUri;
2784
+ this._flags = flags;
2785
+ // React to message broadcasting
2786
+ if (!this.broadcastInitialized) {
2787
+ this.broadcastInitialized = true;
2788
+ this.broadcastService.getMessage()
2789
+ .pipe(takeUntilDestroyed(this.destroyRef))
2790
+ .subscribe(message => {
2791
+ if (message.id === ClipperMessages.LOGOUT) {
2792
+ if (this.loggedIn()) {
2793
+ this.injector.get(ClipperLoginService).logout().subscribe(r => {
2794
+ if (!r.success) {
2795
+ if (r.message) {
2796
+ this.broadcastService.sendMessage(ClipperMessages.ERROR,
2797
+ // The surrounding markup is ours; the server's message is escaped, because
2798
+ // the dialog renders this string as HTML.
2799
+ { message: "<p>" + escapeHtml(r.message) + "</p><br><br><hr><p class='small'><i>Per eliminare la configurazione di Clipper accedere a:<br><b>menu > personalizza > collegamenti</b></i></p>" });
2407
2800
  }
2408
- n.title1 = documentInfo.Title1 ?? documentInfo.Title2;
2409
- n.title2 = documentInfo.Title1 ? documentInfo.Title2 : undefined;
2410
2801
  }
2411
- n.documentDescriptor = undefined;
2412
- items.push(n);
2413
- }
2414
- });
2415
- this.core.bag.set(items);
2416
- }
2417
- }
2418
- });
2419
- }
2420
- /**
2421
- * Adds one or more documents to the working documents bag.
2422
- * @param documentIds - The IDs of the documents to add.
2423
- * @returns The subscription to the add-to-bag request.
2424
- */
2425
- addToBag(documentIds) {
2426
- return this.httpClient
2427
- .post(this.core.serviceUri + '/documents/working/add', { documentIds: documentIds })
2428
- .subscribe((r) => {
2429
- if (!r.success) {
2430
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_CHANGED);
2431
- }
2432
- else {
2433
- if (r.value) {
2434
- const newItems = [];
2435
- r.value.forEach(n => {
2436
- if (n.documentDescriptor) {
2437
- const documentInfo = JSON.parse(n.documentDescriptor);
2438
- if (documentInfo) {
2439
- n.documentId = documentInfo.DocumentId || documentInfo.Id;
2440
- n.title1 = documentInfo.Title1 ?? documentInfo.Title2;
2441
- n.title2 = documentInfo.Title1 ? documentInfo.Title2 : null;
2802
+ else {
2803
+ if ((this.flags & ClipperServiceFlags.DisplayConnectionStateMessages) > 0) {
2804
+ this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Disconnesso da Clipper", icon: 'power_off', duration: 1500 });
2805
+ }
2806
+ // Empty bag
2807
+ this.bag.set([]);
2442
2808
  }
2443
- n.documentDescriptor = undefined;
2444
- newItems.push(n);
2445
- }
2446
- });
2447
- if (newItems.length > 0) {
2448
- this.core.bag.update((values) => [...values, ...newItems]);
2809
+ });
2810
+ }
2811
+ else {
2812
+ // No session to close server-side, but the device must still be left clean.
2813
+ this.clear(true);
2449
2814
  }
2450
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
2451
2815
  }
2452
- }
2453
- });
2816
+ });
2817
+ }
2818
+ // A page refresh is NOT handled here. Announcing LOGIN_COMPLETED from a stored context would
2819
+ // declare a session before anyone has asked the server whether it is still alive, and the
2820
+ // consumer would then treat the inevitable 401 as "your session just expired" rather than as a
2821
+ // bootstrap that never got off the ground. `ClipperLoginService.initialize` -- which
2822
+ // `ClipperService.initialize` runs right after this method -- validates the context against
2823
+ // `/session/me` first and announces the login only if it holds.
2454
2824
  }
2455
2825
  /**
2456
- * Removes a document from the working documents bag.
2457
- * @param documentId - The ID of the document to remove.
2458
- * @returns The subscription to the remove-from-bag request.
2826
+ * Ping
2459
2827
  */
2460
- removeFromBag(documentId) {
2461
- return this.httpClient
2462
- .post(this.core.serviceUri + '/documents/working/delete', { documentIds: [documentId] })
2463
- .subscribe((r) => {
2464
- if (!r.success) {
2465
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
2466
- }
2467
- else {
2468
- const p = this.core.bag().findIndex((n) => n.documentId === documentId);
2469
- if (p !== -1) {
2470
- this.core.bag.update((values) => {
2471
- values.splice(p, 1);
2472
- return [...values];
2473
- });
2474
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
2475
- }
2476
- }
2477
- });
2828
+ ping() {
2829
+ this.httpClient.get(this._serviceUri + '/ping?nocache=' + SystemUtils.generateUUID())
2830
+ .pipe(catchError$1(() => { return EMPTY; }))
2831
+ .subscribe();
2832
+ }
2833
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2834
+ static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService }); }
2835
+ }
2836
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService, decorators: [{
2837
+ type: Service
2838
+ }] });
2839
+
2840
+ /**
2841
+ * The two pieces of account state the document views actually move: which channels are
2842
+ * active, and the unread counters on the dashboard.
2843
+ *
2844
+ * Everything else an account can do — password reset and recovery, settings, channel
2845
+ * activation towards the server, trial info, user links, loading the dashboard — belongs to
2846
+ * the application that owns the account, not to a library whose job is showing documents.
2847
+ * It was removed on 2026-08-25 together with the archive, calendar and collaboration worlds;
2848
+ * a product that needs those endpoints calls them itself.
2849
+ *
2850
+ * What is left touches no HTTP: both methods only mutate the shared state in
2851
+ * `ClipperCoreService` and announce the change on the broadcast bus.
2852
+ */
2853
+ class ClipperAccountService {
2854
+ constructor() {
2855
+ this.broadcastService = inject(BroadcastService);
2856
+ this.core = inject(ClipperCoreService);
2478
2857
  }
2479
2858
  /**
2480
- * Clears all working documents from the bag.
2481
- * @returns The subscription to the clear-bag request.
2859
+ * Toggles the active state of available channels based on the supplied selection list.
2860
+ * @param values - The selected channel items; channels absent from this list are deactivated.
2482
2861
  */
2483
- clearBag() {
2484
- return this.httpClient
2485
- .post(this.core.serviceUri + '/documents/working/delete', { deleteAll: true })
2486
- .subscribe((r) => {
2487
- if (!r.success) {
2488
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
2489
- }
2490
- else {
2491
- this.core.bag.set([]);
2492
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
2862
+ updateChannels(values) {
2863
+ this.core.availableChannels().forEach(channel => {
2864
+ if (!channel.disabled) {
2865
+ channel.active = values?.findIndex(x => x.value === channel.value) !== -1;
2493
2866
  }
2494
2867
  });
2495
2868
  }
2496
2869
  /**
2497
- * Retrieves the saved searches for the given module.
2498
- * @param module - The Clipper module whose saved searches to load.
2499
- * @returns An observable emitting the API result wrapping the list of saved searches.
2500
- */
2501
- loadSearches(module) {
2502
- return this.httpClient
2503
- .get(this.core.serviceUri + '/documents/searches/?module=' + module);
2504
- }
2505
- /**
2506
- * Persists a user search configuration on the server.
2507
- * @param params - The search configuration to save.
2508
- * @returns An observable emitting the API result wrapping the saved search.
2509
- */
2510
- saveSearch(params) {
2511
- return this.httpClient
2512
- .post(this.core.serviceUri + '/documents/searches/save', params);
2513
- }
2514
- /**
2515
- * Deletes a saved search by its ID.
2516
- * @param id - The ID of the saved search to remove.
2517
- * @returns An observable emitting the API result wrapping the number of deleted searches.
2870
+ * Adjusts the unread-item counter for the given module and broadcasts a dashboard update.
2871
+ * @param module - The Clipper module whose counter should be adjusted.
2872
+ * @param model - Optional document model to further scope the counter update.
2873
+ * @param increment - The signed increment to apply (use negative values to decrement).
2518
2874
  */
2519
- deleteSearch(id) {
2520
- return this.httpClient
2521
- .post(this.core.serviceUri + '/documents/searches/delete', { id: id });
2875
+ updateUnreadItems(module, model, increment) {
2876
+ increment ??= 0;
2877
+ if (increment !== 0) {
2878
+ this.core.dashboard.updateUnreadItems(module, model, increment);
2879
+ }
2880
+ this.broadcastService.sendMessage(ClipperMessages.COMMAND_DASHBOARD_UPDATED);
2522
2881
  }
2523
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2524
- static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService }); }
2882
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2883
+ static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService }); }
2525
2884
  }
2526
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService, decorators: [{
2885
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService, decorators: [{
2527
2886
  type: Service
2528
2887
  }] });
2529
2888
 
2530
2889
  /**
2531
- * Authentication: credential / OAuth2 login, MFA confirmation, logout and OTP.
2532
- * Shared login context, channels and the logged-in flag live in `ClipperCoreService`.
2890
+ * Document search, references, export and metadata, plus the dashboard counters,
2891
+ * taxonomy/topics/tags lookups, the working-documents "bag" and saved searches.
2533
2892
  */
2534
- class ClipperLoginService {
2893
+ class ClipperDocumentsService {
2535
2894
  constructor() {
2536
2895
  this.httpClient = inject(HttpClient);
2537
2896
  this.broadcastService = inject(BroadcastService);
2538
- this.splashService = inject(SplashService);
2539
2897
  this.core = inject(ClipperCoreService);
2540
2898
  }
2899
+ /////
2900
+ // DOCUMENTS
2901
+ /////
2541
2902
  /**
2542
- * Attempts an automatic login using the credentials currently stored in session storage.
2543
- * @param onSuccess - Optional callback invoked with the login result value on success.
2544
- */
2545
- autoLogin(onSuccess) {
2546
- this.login(undefined, undefined, true)
2547
- .subscribe({
2548
- next: r => {
2549
- if (!r.success) {
2550
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
2551
- }
2552
- else {
2553
- if (!r.value.requiresMfa) {
2554
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Connesso a Clipper", icon: 'power', duration: 1500 });
2555
- }
2556
- if (onSuccess) {
2557
- onSuccess(r.value);
2558
- }
2559
- }
2560
- },
2561
- error: () => { this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: "Clipper non disponibile." }); }
2562
- });
2563
- }
2564
- /**
2565
- * Performs an automatic logout and clears the stored session.
2566
- * @param onSuccess - Optional callback invoked after a successful logout.
2567
- */
2568
- autoLogout(onSuccess) {
2569
- this.logout()
2570
- .subscribe({
2571
- next: r => {
2572
- if (!r.success) {
2573
- if (r.message) {
2574
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
2575
- }
2576
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_CHANGED);
2577
- }
2578
- else {
2579
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Disconnesso da Clipper", icon: 'power_off', duration: 1500 });
2580
- }
2581
- },
2582
- error: () => { },
2583
- complete: () => {
2584
- if (onSuccess) {
2585
- onSuccess();
2586
- }
2587
- }
2588
- });
2589
- }
2590
- /**
2591
- * Authenticates the user against the Clipper API, supporting both credential-based and
2592
- * OAuth2 login flows.
2593
- * @param email - The user's email address (credential login only).
2594
- * @param password - The user's password (credential login only).
2595
- * @param remember - When `true`, the session is remembered across browser restarts.
2596
- * @param oauth - The OAuth2 provider type, when authenticating via SSO.
2597
- * @param oauthAccessToken - The OAuth2 bearer token. Defaults to the value stored in session storage.
2598
- * @returns An observable emitting the API result wrapping the login result.
2903
+ * Queries documents matching the given search parameters.
2904
+ * @param params - The document search parameters.
2905
+ * @returns An observable emitting the API result wrapping the search result.
2599
2906
  */
2600
- login(email, password, remember, oauth, oauthAccessToken = sessionStorage.getItem("clipper_oauth_token") ?? undefined) {
2601
- this.splashService.setMessage('Accesso in corso...');
2602
- return this.httpClient
2603
- .post(this.core.serviceUri + '/login2', {
2604
- user: oauth ? null : email,
2605
- password: oauth ? null : password,
2606
- remember: remember,
2607
- oauth: oauth
2608
- }, {
2609
- headers: !oauth || !oauthAccessToken
2610
- ? new HttpHeaders()
2611
- : new HttpHeaders()
2612
- .set("Authorization", 'Bearer ' + oauthAccessToken)
2613
- })
2614
- .pipe(catchError$1(err => {
2615
- return throwError(() => err);
2616
- }), map((r) => {
2617
- if (r.success) {
2618
- const info = this.core.ensureLoginInfo();
2619
- info.oauth = oauth;
2620
- info.remember = remember;
2621
- if (!oauth && r.value.requiresMfa) {
2622
- // Notify login is pending
2623
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_PENDING);
2624
- }
2625
- else {
2626
- // Complete login
2627
- this.completeLogin(r.value);
2628
- }
2629
- }
2630
- return r;
2631
- }));
2907
+ query(params) {
2908
+ return this.httpClient.post(this.core.serviceUri + '/documents', params);
2632
2909
  }
2633
2910
  /**
2634
- * Finalises the login flow by updating the stored context, setting the logged-in signal,
2635
- * initialising channels, and broadcasting `LOGIN_COMPLETED`.
2636
- * @param result - The login result returned by the API.
2911
+ * Retrieves the facets for a document query.
2912
+ * @param params - The document search parameters.
2913
+ * @returns An observable emitting the API result wrapping the search facets.
2637
2914
  */
2638
- completeLogin(result) {
2639
- // Update context info
2640
- this.core.updateContext(result);
2641
- this.core.setLoggedIn(!result.context?.isTemporary);
2642
- this.core.loggingIn.set(false);
2643
- // Initialize channels
2644
- this.core.initializeChannels();
2645
- // Notify
2646
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2915
+ queryFacets(params) {
2916
+ return this.httpClient.post(this.core.serviceUri + '/documents/facets', params);
2647
2917
  }
2648
2918
  /**
2649
- * Submits the MFA confirmation code to complete a two-factor login flow.
2650
- * @param code - The one-time confirmation code provided to the user.
2651
- * @returns An observable emitting the API result wrapping the login result.
2919
+ * Updates the state of one or more documents.
2920
+ * @param params - The document state update parameters.
2921
+ * @returns An observable emitting the API result wrapping the number of updated documents.
2652
2922
  */
2653
- confirmIdentity(code) {
2654
- return this.httpClient
2655
- .post(this.core.serviceUri + '/login/confirm/' + code, {})
2656
- .pipe(catchError$1((err) => {
2657
- return throwError(() => err);
2658
- }), map((r) => {
2659
- if (r.success) {
2660
- this.completeLogin(r.value);
2661
- }
2662
- return r;
2663
- }));
2923
+ updateState(params) {
2924
+ return this.httpClient.post(this.core.serviceUri + '/documents/state/update', params);
2664
2925
  }
2665
2926
  /**
2666
- * Logs the user out and clears the current session.
2667
- * @param forget - When `true`, all stored user credentials are also removed.
2668
- * @returns An observable that completes once the logout request has been processed.
2927
+ * Exports a single document in PDF format.
2928
+ * @param id - The ID of the document to export.
2929
+ * @returns An observable emitting the PDF binary content as a blob.
2669
2930
  */
2670
- logout(forget = false) {
2671
- return this.httpClient.post(this.core.serviceUri + '/logout/?forget=' + forget, {})
2672
- .pipe(finalize(() => {
2673
- this.core.clear(true);
2674
- // Clean up
2675
- localStorage.removeItem('clipper_context');
2676
- }), catchError$1((_e) => {
2677
- return of([]);
2678
- }));
2931
+ exportPdf(id) {
2932
+ return this.httpClient.get(this.core.serviceUri + '/documents/export/' + id, { responseType: 'blob' });
2679
2933
  }
2680
2934
  /**
2681
- * Requests a new one-time password for the given repository.
2682
- * @param id - The repository ID for which the OTP should be generated.
2683
- * @returns An observable emitting the API result wrapping the generated OTP info.
2935
+ * Exports a document list (query or selected items), or exports deadlines as ICS.
2936
+ * @param params - The export parameters.
2937
+ * @returns An observable emitting the exported content as a blob.
2684
2938
  */
2685
- newOTP(id) {
2686
- return this.httpClient.get(this.core.serviceUri + '/otp/new/?id=' + id);
2939
+ export(params) {
2940
+ return this.httpClient.post(this.core.serviceUri + '/documents/export', params, { responseType: 'blob' });
2687
2941
  }
2688
- ////
2689
- // CONTEXT (re-published from ClipperCoreService for consumer convenience)
2690
- ////
2691
2942
  /**
2692
- * Persists the current login context to `localStorage`.
2693
- * Delegates to `ClipperCoreService.storeContext()`.
2943
+ * Sends document links by email.
2944
+ * @param params - The send-by-email parameters including recipients and documents.
2945
+ * @returns An observable emitting the API result wrapping the number of sent items.
2694
2946
  */
2695
- storeContext() {
2696
- this.core.storeContext();
2947
+ sendTo(params) {
2948
+ return this.httpClient.post(this.core.serviceUri + '/documents/send', params);
2697
2949
  }
2698
2950
  /**
2699
- * Updates the stored login context with the values from a fresh login result.
2700
- * Delegates to `ClipperCoreService.updateContext()`.
2701
- * @param result - The login result containing the new user context and channel settings.
2951
+ * Retrieves the full document report page.
2952
+ * @param id - The ID of the document.
2953
+ * @returns An observable emitting the report content as a blob.
2702
2954
  */
2703
- updateContext(result) {
2704
- this.core.updateContext(result);
2955
+ report(id) {
2956
+ return this.httpClient.get(this.core.serviceUri + '/documents/report/' + id, { responseType: 'blob' });
2705
2957
  }
2706
2958
  /**
2707
- * Clears all session-storage authentication keys and resets the login state.
2708
- * Delegates to `ClipperCoreService.clear()`.
2709
- * @param clearOAuthToken - When `true`, the OAuth bearer token is also removed.
2959
+ * Gets the comment associated with a document.
2960
+ * @param id - The ID of the document.
2961
+ * @returns An observable emitting the API result wrapping the comment text.
2710
2962
  */
2711
- clear(clearOAuthToken = false) {
2712
- this.core.clear(clearOAuthToken);
2713
- }
2714
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2715
- static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService }); }
2716
- }
2717
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperLoginService, decorators: [{
2718
- type: Service
2719
- }] });
2720
-
2721
- /**
2722
- * Shared state and bootstrap for the Clipper application.
2723
- *
2724
- * Holds every piece of state used by more than one feature service (service URI,
2725
- * flags, login context, dashboard, working-documents bag, channels and the various
2726
- * UI signals) plus the low-level helpers that mutate that state.
2727
- *
2728
- * The feature services (`ClipperLoginService`, `ClipperDocumentsService`, ...) inject
2729
- * this service to read shared state and call shared helpers. `ClipperService` (the
2730
- * barrel/facade) re-exports the state declared here.
2731
- */
2732
- class ClipperCoreService {
2733
- constructor() {
2734
- this.httpClient = inject(HttpClient);
2735
- this.destroyRef = inject(DestroyRef);
2736
- this.broadcastService = inject(BroadcastService);
2737
- /** Used to lazily resolve feature services inside `initialize`, avoiding circular DI. */
2738
- this.injector = inject(Injector);
2739
- this.broadcastInitialized = false;
2740
- this._serviceUri = '';
2741
- this._flags = ClipperServiceFlags.None;
2742
- this._loggedIn = signal(sessionStorage.getItem("clipper_oauth_token") !== null, /* @ts-ignore */
2743
- ...(ngDevMode ? [{ debugName: "_loggedIn" }] : /* istanbul ignore next */ []));
2744
- this.loggedIn = this._loggedIn.asReadonly();
2745
- this.loggingIn = signal(false, /* @ts-ignore */
2746
- ...(ngDevMode ? [{ debugName: "loggingIn" }] : /* istanbul ignore next */ []));
2747
- this.snapshot = signal(undefined, /* @ts-ignore */
2748
- ...(ngDevMode ? [{ debugName: "snapshot" }] : /* istanbul ignore next */ []));
2749
- this.supportsRS = signal(false, /* @ts-ignore */
2750
- ...(ngDevMode ? [{ debugName: "supportsRS" }] : /* istanbul ignore next */ []));
2751
- this.referencesSnapshot = signal(undefined, /* @ts-ignore */
2752
- ...(ngDevMode ? [{ debugName: "referencesSnapshot" }] : /* istanbul ignore next */ []));
2753
- this.dashboard = new ClipperDashboard();
2754
- this.bag = signal([], /* @ts-ignore */
2755
- ...(ngDevMode ? [{ debugName: "bag" }] : /* istanbul ignore next */ []));
2756
- this.bagTotal = computed(() => this.bag().length, /* @ts-ignore */
2757
- ...(ngDevMode ? [{ debugName: "bagTotal" }] : /* istanbul ignore next */ []));
2758
- this.visible = signal(false, /* @ts-ignore */
2759
- ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
2760
- this.availableChannels = signal([], /* @ts-ignore */
2761
- ...(ngDevMode ? [{ debugName: "availableChannels" }] : /* istanbul ignore next */ []));
2762
- this.activeChannels = computed(() => {
2763
- return this.availableChannels()?.filter(x => !x.suspended && !x.disabled && x.active === true);
2764
- }, /* @ts-ignore */
2765
- ...(ngDevMode ? [{ debugName: "activeChannels" }] : /* istanbul ignore next */ []));
2766
- this.allowTags = signal(false, /* @ts-ignore */
2767
- ...(ngDevMode ? [{ debugName: "allowTags" }] : /* istanbul ignore next */ []));
2963
+ comment(id) {
2964
+ return this.httpClient.get(this.core.serviceUri + '/documents/comment/' + id);
2768
2965
  }
2769
- /** @returns The URI of the Clipper web application, or `undefined` if not set. */
2770
- get appUri() {
2771
- return this._appUri;
2966
+ /**
2967
+ * Gets the info for a document.
2968
+ * @param id - The ID of the document.
2969
+ * @returns An observable emitting the API result wrapping the document info.
2970
+ */
2971
+ info(id) {
2972
+ return this.httpClient.get(this.core.serviceUri + '/documents/info/' + id);
2772
2973
  }
2773
- /** @returns The base URI of the Clipper REST API. */
2774
- get serviceUri() {
2775
- return this._serviceUri;
2974
+ /**
2975
+ * Gets the structure (index) of a document.
2976
+ * @param id - The ID of the document.
2977
+ * @returns An observable emitting the API result wrapping the document structure.
2978
+ */
2979
+ index(id) {
2980
+ return this.httpClient.get(this.core.serviceUri + '/documents/structure/' + id);
2776
2981
  }
2777
- /** @returns The active feature flags. */
2778
- get flags() {
2779
- return this._flags;
2982
+ /**
2983
+ * Gets the last-update metadata for a document.
2984
+ *
2985
+ * The response carries two things, not one: the last-update string and the shared notes attached
2986
+ * to the document. The second was long left undeclared here, so a consumer of this library could
2987
+ * not see data the API had already sent it.
2988
+ * @param id - The ID of the document.
2989
+ * @returns An observable emitting the API result wrapping the last update string and its notes.
2990
+ */
2991
+ metadata(id) {
2992
+ return this.httpClient.get(this.core.serviceUri + '/documents/metadata/' + id);
2780
2993
  }
2781
2994
  /**
2782
- * Lazily loads the login context from `localStorage` on first access.
2783
- * @returns The current login context, or `undefined` if not authenticated.
2995
+ * Queries document references, changes or jurisprudence depending on the requested mode.
2996
+ * @param params - The references search parameters; `mode` defaults to `ReferencesIn`.
2997
+ * @returns An observable emitting the API result wrapping the search result, or `null` for an unsupported mode.
2784
2998
  */
2785
- get loginInfo() {
2786
- if (!this._loginInfo) {
2787
- const loginInfo = localStorage.getItem('clipper_context');
2788
- if (loginInfo) {
2789
- try {
2790
- this._loginInfo = JSON.parse(loginInfo);
2791
- }
2792
- catch { }
2793
- }
2999
+ references(params) {
3000
+ let mode = params.mode;
3001
+ if (!mode)
3002
+ mode = ClipperQueryReferencesMode.ReferencesIn;
3003
+ switch (mode) {
3004
+ case ClipperQueryReferencesMode.ReferencesIn:
3005
+ case ClipperQueryReferencesMode.ReferencesOut:
3006
+ params.mode = mode;
3007
+ return this.httpClient.post(this.core.serviceUri + '/documents/references', params);
3008
+ case ClipperQueryReferencesMode.ChangesIn:
3009
+ case ClipperQueryReferencesMode.ChangesOut:
3010
+ return this.httpClient.post(this.core.serviceUri + '/documents/changes', params);
3011
+ case ClipperQueryReferencesMode.Juris:
3012
+ return this.httpClient.post(this.core.serviceUri + '/documents/juris', params);
3013
+ default:
3014
+ return null;
2794
3015
  }
2795
- return this._loginInfo;
2796
3016
  }
2797
- ////
2798
- // SHARED MUTATORS
2799
- ////
2800
3017
  /**
2801
- * Ensures a login-info object exists and returns it for further mutation.
2802
- * Replaces the inline `if (!this._loginInfo) { ... }` guards now that the field
2803
- * is private to this service.
2804
- * @returns The existing login-info object, or a freshly created empty one.
3018
+ * Retrieves the facets for a document references query.
3019
+ * @param params - The references search parameters; `mode` defaults to `ReferencesIn`.
3020
+ * @returns An observable emitting the API result wrapping the search facets, or `null` for an unsupported mode.
2805
3021
  */
2806
- ensureLoginInfo() {
2807
- if (!this._loginInfo) {
2808
- this._loginInfo = { context: undefined };
3022
+ referencesFacets(params) {
3023
+ let mode = params.mode ?? ClipperQueryReferencesMode.ReferencesIn;
3024
+ switch (mode) {
3025
+ case ClipperQueryReferencesMode.ReferencesIn:
3026
+ case ClipperQueryReferencesMode.ReferencesOut:
3027
+ params.mode = mode;
3028
+ return this.httpClient.post(this.core.serviceUri + '/documents/references/facets', params);
3029
+ case ClipperQueryReferencesMode.ChangesIn:
3030
+ case ClipperQueryReferencesMode.ChangesOut:
3031
+ return this.httpClient.post(this.core.serviceUri + '/documents/changes/facets', params);
3032
+ case ClipperQueryReferencesMode.Juris:
3033
+ return this.httpClient.post(this.core.serviceUri + '/documents/juris/facets', params);
3034
+ default: return null;
2809
3035
  }
2810
- return this._loginInfo;
2811
3036
  }
2812
3037
  /**
2813
- * Sets the logged-in state. The backing signal is read-only to consumers, so the
2814
- * feature services use this method instead.
2815
- * @param value - The new logged-in state.
3038
+ * Wraps document rendering to allow token refresh.
3039
+ * @returns An observable emitting the API result wrapping a boolean readiness flag.
2816
3040
  */
2817
- setLoggedIn(value) {
2818
- this._loggedIn.set(value);
3041
+ preRender() {
3042
+ return this.httpClient.get(this.core.serviceUri + '/documents/pre-render?nocache=' + SystemUtils.generateUUID());
2819
3043
  }
2820
3044
  /**
2821
- * Persists the current login context to `localStorage`.
3045
+ * Gets the jurisprudence articles for a document query.
3046
+ * @param params - The document search parameters.
3047
+ * @returns An observable emitting the API result wrapping the search result.
2822
3048
  */
2823
- storeContext() {
2824
- localStorage.setItem('clipper_context', JSON.stringify(this._loginInfo));
3049
+ jurisArticles(params) {
3050
+ return this.httpClient.post(this.core.serviceUri + '/documents/juris/articles', params);
2825
3051
  }
2826
3052
  /**
2827
- * Updates the stored login context with the values from a fresh login result.
2828
- * @param result - The login result containing the new user context and channel settings.
3053
+ * Gets a deadlines snapshot based on the supplied deadlines.
3054
+ * @param params - The calendar search parameters.
3055
+ * @returns An observable emitting the API result wrapping the calendar snapshot result.
2829
3056
  */
2830
- updateContext(result) {
2831
- const info = this.ensureLoginInfo();
2832
- info.context = result.context;
2833
- info.channels = result.settings;
2834
- this.storeContext();
3057
+ deadlinesSnapshot(params) {
3058
+ return this.httpClient.post(this.core.serviceUri + '/documents/calendar/snapshot', params);
2835
3059
  }
2836
3060
  /**
2837
- * Rebuilds the `availableChannels` signal from the current login context.
2838
- */
2839
- initializeChannels() {
2840
- if (this.loginInfo) {
2841
- const channels = [];
2842
- this.loginInfo.channels?.forEach(n => {
2843
- const channelSubscription = this.loginInfo?.context?.channels?.find(x => x.channel === n.channelId);
2844
- n.isSuspended = channelSubscription?.isSuspended === true;
2845
- const channel = ClipperChannels.find(x => x.value === n.channelId);
2846
- if (channel) {
2847
- channel.disabled = !n.isActive;
2848
- channel.suspended = n.isSuspended === true;
2849
- channel.active = n.isActive === true && n.isEnabled === true;
2850
- channels.push(channel);
2851
- }
2852
- });
2853
- this.availableChannels.set(channels);
2854
- }
3061
+ * Retrieves the taxonomy.
3062
+ * @param params - Optional taxonomy parameters. Defaults to `{ model: 0, countItems: false }`.
3063
+ * @returns An observable emitting the API result wrapping the taxonomy folder tree.
3064
+ */
3065
+ getTaxonomy(params) {
3066
+ return this.httpClient.post(this.core.serviceUri + '/taxonomy', params ?? { model: 0, countItems: false });
2855
3067
  }
2856
3068
  /**
2857
- * Resets the login state, clears the stored login info, and broadcasts `LOGOUT_COMPLETED`.
3069
+ * Retrieves the topics as a flat list.
3070
+ * @returns An observable emitting the API result wrapping the list of topics.
2858
3071
  */
2859
- reset() {
2860
- // Clear login info
2861
- this._loginInfo = undefined;
2862
- // Logged out
2863
- this._loggedIn.set(false);
2864
- // Reset channels
2865
- this.availableChannels.set([]);
2866
- // Notify
2867
- this.broadcastService.sendMessage(ClipperMessages.LOGOUT_COMPLETED);
3072
+ getTopics() {
3073
+ return this.httpClient.get(this.core.serviceUri + '/topics');
2868
3074
  }
2869
3075
  /**
2870
- * Clears all session-storage authentication keys and resets the login state.
2871
- * @param clearOAuthToken - When `true`, the OAuth bearer token is also removed.
3076
+ * Retrieves the topics as a tree.
3077
+ * @returns An observable emitting the API result wrapping the topics folder tree.
2872
3078
  */
2873
- clear(clearOAuthToken = false) {
2874
- // Clear local storage
2875
- sessionStorage.removeItem('clipper_auth');
2876
- sessionStorage.removeItem('clipper_oauth');
2877
- if (clearOAuthToken) {
2878
- sessionStorage.removeItem('clipper_oauth_token');
2879
- }
2880
- // Reset login
2881
- this.reset();
3079
+ getTopicsAsTree() {
3080
+ return this.httpClient.get(this.core.serviceUri + '/topics2');
2882
3081
  }
2883
- ////
2884
- // BOOTSTRAP
2885
- ////
2886
3082
  /**
2887
- * Initialises the service with the API base URI, optional app URI, and feature flags.
2888
- * @param serviceUri - The base URI of the Clipper REST API.
2889
- * @param appUri - Optional URI of the Clipper web application (used to build document links).
2890
- * @param flags - Feature flags that control service behaviour. Defaults to `ClipperServiceFlags.None`.
3083
+ * Retrieves the tags.
3084
+ * @returns An observable emitting the API result wrapping the list of tags.
2891
3085
  */
2892
- initialize(serviceUri, appUri, flags = ClipperServiceFlags.None) {
2893
- // Create unique client and machine id
2894
- if (!sessionStorage.getItem('clipper_client_id')) {
2895
- sessionStorage.setItem('clipper_client_id', (flags && ClipperServiceFlags.Embedded) > 0
2896
- ? 'embedded'
2897
- : SystemUtils.generateUUID());
2898
- }
2899
- // Initialize
2900
- this._serviceUri = serviceUri;
2901
- this._appUri = appUri;
2902
- this._flags = flags;
2903
- // React to message broadcasting
2904
- if (!this.broadcastInitialized) {
2905
- this.broadcastInitialized = true;
2906
- this.broadcastService.getMessage()
2907
- .pipe(takeUntilDestroyed(this.destroyRef))
2908
- .subscribe(message => {
2909
- if (message.id === ClipperMessages.LOGIN_CHANGED) {
2910
- this.injector.get(ClipperLoginService).login(undefined, undefined, true, message.data?.oauth ?? undefined, message.data?.oauthAccessToken ?? undefined).subscribe({
2911
- next: r => {
2912
- if (!r.success) {
2913
- if ((this.flags & ClipperServiceFlags.DisplayConnectionStateMessages) > 0) {
2914
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: "Le credenziali di accesso sono cambiate o non sono più valide. Esegui un nuovo accesso." });
2915
- }
2916
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_FAILED);
2917
- }
2918
- else {
2919
- if ((this.flags & ClipperServiceFlags.DisplayConnectionStateMessages) > 0) {
2920
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Connesso a Clipper", icon: 'power', duration: 1500 });
3086
+ getTags() {
3087
+ return this.httpClient.get(this.core.serviceUri + '/tags');
3088
+ }
3089
+ ///
3090
+ // BAG
3091
+ ///
3092
+ /**
3093
+ * Loads the working documents and populates the shared bag.
3094
+ * @returns The subscription to the working-documents request.
3095
+ */
3096
+ loadBag() {
3097
+ return this.httpClient
3098
+ .get(this.core.serviceUri + '/documents/working')
3099
+ .subscribe(r => {
3100
+ if (!r.success) {
3101
+ this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
3102
+ }
3103
+ else {
3104
+ if (r.value) {
3105
+ const items = [];
3106
+ r.value.forEach(n => {
3107
+ if (n.documentDescriptor) {
3108
+ const documentInfo = JSON.parse(n.documentDescriptor);
3109
+ if (documentInfo) {
3110
+ if (!n.documentId) {
3111
+ n.documentId = documentInfo.DocumentId || documentInfo.Id;
2921
3112
  }
2922
- // Load bag
2923
- this.injector.get(ClipperDocumentsService).loadBag();
3113
+ n.title1 = documentInfo.Title1 ?? documentInfo.Title2;
3114
+ n.title2 = documentInfo.Title1 ? documentInfo.Title2 : undefined;
2924
3115
  }
2925
- },
2926
- error: () => { console.error("Clipper non disponibile."); } // Avoid unwanted errors on client
3116
+ n.documentDescriptor = undefined;
3117
+ items.push(n);
3118
+ }
2927
3119
  });
3120
+ this.core.bag.set(items);
2928
3121
  }
2929
- else if (message.id === ClipperMessages.LOGOUT) {
2930
- if (this.loggedIn()) {
2931
- this.injector.get(ClipperLoginService).logout().subscribe(r => {
2932
- if (!r.success) {
2933
- if (r.message) {
2934
- this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: "<p>" + r.message + "</p><br><br><hr><p class='small'><i>Per eliminare la configurazione di Clipper accedere a:<br><b>menu > personalizza > collegamenti</b></i></p>" });
2935
- }
2936
- }
2937
- else {
2938
- if ((this.flags & ClipperServiceFlags.DisplayConnectionStateMessages) > 0) {
2939
- this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Disconnesso da Clipper", icon: 'power_off', duration: 1500 });
2940
- }
2941
- // Empty bag
2942
- this.bag.set([]);
3122
+ }
3123
+ });
3124
+ }
3125
+ /**
3126
+ * Adds one or more documents to the working documents bag.
3127
+ * @param documentIds - The IDs of the documents to add.
3128
+ * @returns The subscription to the add-to-bag request.
3129
+ */
3130
+ addToBag(documentIds) {
3131
+ return this.httpClient
3132
+ .post(this.core.serviceUri + '/documents/working/add', { documentIds: documentIds })
3133
+ .subscribe((r) => {
3134
+ // A failure needs no broadcast here: `clipperAuthInterceptor` already turns an expired
3135
+ // session into an `ERROR_401` message and reports every other status the same way.
3136
+ if (r.success) {
3137
+ if (r.value) {
3138
+ const newItems = [];
3139
+ r.value.forEach(n => {
3140
+ if (n.documentDescriptor) {
3141
+ const documentInfo = JSON.parse(n.documentDescriptor);
3142
+ if (documentInfo) {
3143
+ n.documentId = documentInfo.DocumentId || documentInfo.Id;
3144
+ n.title1 = documentInfo.Title1 ?? documentInfo.Title2;
3145
+ n.title2 = documentInfo.Title1 ? documentInfo.Title2 : null;
2943
3146
  }
2944
- });
2945
- }
2946
- else {
2947
- this.clear();
3147
+ n.documentDescriptor = undefined;
3148
+ newItems.push(n);
3149
+ }
3150
+ });
3151
+ if (newItems.length > 0) {
3152
+ this.core.bag.update((values) => [...values, ...newItems]);
2948
3153
  }
3154
+ this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
2949
3155
  }
2950
- });
2951
- }
2952
- // Eveluate current session storage in case of page refresh (F5)
2953
- if (this.loggedIn()) {
2954
- // Auto login
2955
- this.loggingIn.set(false);
2956
- // Initialize channels
2957
- this.initializeChannels();
2958
- // Notify
2959
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2960
- }
3156
+ }
3157
+ });
2961
3158
  }
2962
3159
  /**
2963
- * Ping
3160
+ * Removes a document from the working documents bag.
3161
+ * @param documentId - The ID of the document to remove.
3162
+ * @returns The subscription to the remove-from-bag request.
2964
3163
  */
2965
- ping() {
2966
- this.httpClient.get(this._serviceUri + '/ping?nocache=' + SystemUtils.generateUUID())
2967
- .pipe(catchError$1(() => { return EMPTY; }))
2968
- .subscribe();
2969
- }
2970
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
2971
- static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService }); }
2972
- }
2973
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperCoreService, decorators: [{
2974
- type: Service
2975
- }] });
2976
-
2977
- /**
2978
- * The two pieces of account state the document views actually move: which channels are
2979
- * active, and the unread counters on the dashboard.
2980
- *
2981
- * Everything else an account can do — password reset and recovery, settings, channel
2982
- * activation towards the server, trial info, user links, loading the dashboard — belongs to
2983
- * the application that owns the account, not to a library whose job is showing documents.
2984
- * It was removed on 2026-08-25 together with the archive, calendar and collaboration worlds;
2985
- * a product that needs those endpoints calls them itself.
2986
- *
2987
- * What is left touches no HTTP: both methods only mutate the shared state in
2988
- * `ClipperCoreService` and announce the change on the broadcast bus.
2989
- */
2990
- class ClipperAccountService {
2991
- constructor() {
2992
- this.broadcastService = inject(BroadcastService);
2993
- this.core = inject(ClipperCoreService);
3164
+ removeFromBag(documentId) {
3165
+ return this.httpClient
3166
+ .post(this.core.serviceUri + '/documents/working/delete', { documentIds: [documentId] })
3167
+ .subscribe((r) => {
3168
+ if (!r.success) {
3169
+ this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
3170
+ }
3171
+ else {
3172
+ const p = this.core.bag().findIndex((n) => n.documentId === documentId);
3173
+ if (p !== -1) {
3174
+ this.core.bag.update((values) => {
3175
+ values.splice(p, 1);
3176
+ return [...values];
3177
+ });
3178
+ this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
3179
+ }
3180
+ }
3181
+ });
2994
3182
  }
2995
3183
  /**
2996
- * Toggles the active state of available channels based on the supplied selection list.
2997
- * @param values - The selected channel items; channels absent from this list are deactivated.
3184
+ * Clears all working documents from the bag.
3185
+ * @returns The subscription to the clear-bag request.
2998
3186
  */
2999
- updateChannels(values) {
3000
- this.core.availableChannels().forEach(channel => {
3001
- if (!channel.disabled) {
3002
- channel.active = values?.findIndex(x => x.value === channel.value) !== -1;
3187
+ clearBag() {
3188
+ return this.httpClient
3189
+ .post(this.core.serviceUri + '/documents/working/delete', { deleteAll: true })
3190
+ .subscribe((r) => {
3191
+ if (!r.success) {
3192
+ this.broadcastService.sendMessage(ClipperMessages.ERROR, { message: r.message });
3193
+ }
3194
+ else {
3195
+ this.core.bag.set([]);
3196
+ this.broadcastService.sendMessage(ClipperMessages.SUCCESS_TOAST, { message: "Operazione completata con successo.", icon: 'check', duration: 1500 });
3003
3197
  }
3004
3198
  });
3005
3199
  }
3006
3200
  /**
3007
- * Adjusts the unread-item counter for the given module and broadcasts a dashboard update.
3008
- * @param module - The Clipper module whose counter should be adjusted.
3009
- * @param model - Optional document model to further scope the counter update.
3010
- * @param increment - The signed increment to apply (use negative values to decrement).
3201
+ * Retrieves the saved searches for the given module.
3202
+ * @param module - The Clipper module whose saved searches to load.
3203
+ * @returns An observable emitting the API result wrapping the list of saved searches.
3011
3204
  */
3012
- updateUnreadItems(module, model, increment) {
3013
- increment ??= 0;
3014
- if (increment !== 0) {
3015
- this.core.dashboard.updateUnreadItems(module, model, increment);
3016
- }
3017
- this.broadcastService.sendMessage(ClipperMessages.COMMAND_DASHBOARD_UPDATED);
3205
+ loadSearches(module) {
3206
+ return this.httpClient
3207
+ .get(this.core.serviceUri + '/documents/searches/?module=' + module);
3018
3208
  }
3019
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3020
- static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService }); }
3209
+ /**
3210
+ * Persists a user search configuration on the server.
3211
+ * @param params - The search configuration to save.
3212
+ * @returns An observable emitting the API result wrapping the saved search.
3213
+ */
3214
+ saveSearch(params) {
3215
+ return this.httpClient
3216
+ .post(this.core.serviceUri + '/documents/searches/save', params);
3217
+ }
3218
+ /**
3219
+ * Deletes a saved search by its ID.
3220
+ * @param id - The ID of the saved search to remove.
3221
+ * @returns An observable emitting the API result wrapping the number of deleted searches.
3222
+ */
3223
+ deleteSearch(id) {
3224
+ return this.httpClient
3225
+ .post(this.core.serviceUri + '/documents/searches/delete', { id: id });
3226
+ }
3227
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3228
+ static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService }); }
3021
3229
  }
3022
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperAccountService, decorators: [{
3230
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ClipperDocumentsService, decorators: [{
3023
3231
  type: Service
3024
3232
  }] });
3025
3233
 
@@ -3034,7 +3242,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
3034
3242
  class ClipperService {
3035
3243
  constructor() {
3036
3244
  this.core = inject(ClipperCoreService);
3037
- /** Authentication: login/logout, MFA, OTP. */
3245
+ /** Authentication: login/logout, e-mailed code confirmation, session restore, OTP. */
3038
3246
  this.session = inject(ClipperLoginService);
3039
3247
  /** Document search, references, export, working-documents bag and saved searches. */
3040
3248
  this.documents = inject(ClipperDocumentsService);
@@ -3080,13 +3288,20 @@ class ClipperService {
3080
3288
  // BOOTSTRAP
3081
3289
  ////
3082
3290
  /**
3083
- * Initialises the application with the API base URI, optional app URI, and feature flags.
3291
+ * Initialises the application with the API base URI, optional app URI, and feature flags, then
3292
+ * validates any session that survived a page refresh.
3084
3293
  * @param serviceUri - The base URI of the Clipper REST API.
3085
3294
  * @param appUri - Optional URI of the Clipper web application (used to build document links).
3086
3295
  * @param flags - Feature flags that control service behaviour. Defaults to `ClipperServiceFlags.None`.
3296
+ * @returns void
3087
3297
  */
3088
3298
  initialize(serviceUri, appUri, flags = ClipperServiceFlags.None) {
3089
3299
  this.core.initialize(serviceUri, appUri, flags);
3300
+ // Second, and in this order: the core sets the base URI that `/session/me` is built from, and
3301
+ // it no longer declares a session from what local storage holds. Validating that context is
3302
+ // what `ClipperLoginService.initialize` does, and doing it from here means a consumer keeps
3303
+ // calling one method to bootstrap, exactly as before.
3304
+ this.session.initialize();
3090
3305
  }
3091
3306
  /**
3092
3307
  * Pings the Clipper REST API to keep the session/token alive.