@iebh/tera-fy 1.0.8 → 1.0.10

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.
Files changed (36) hide show
  1. package/README.md +9 -4
  2. package/api.md +445 -431
  3. package/dist/terafy.js +2 -2
  4. package/dist/terafy.js.map +4 -4
  5. package/docs/assets/anchor.js +350 -0
  6. package/docs/assets/bass-addons.css +12 -0
  7. package/docs/assets/bass.css +544 -0
  8. package/docs/assets/fonts/EOT/SourceCodePro-Bold.eot +0 -0
  9. package/docs/assets/fonts/EOT/SourceCodePro-Regular.eot +0 -0
  10. package/docs/assets/fonts/LICENSE.txt +93 -0
  11. package/docs/assets/fonts/OTF/SourceCodePro-Bold.otf +0 -0
  12. package/docs/assets/fonts/OTF/SourceCodePro-Regular.otf +0 -0
  13. package/docs/assets/fonts/TTF/SourceCodePro-Bold.ttf +0 -0
  14. package/docs/assets/fonts/TTF/SourceCodePro-Regular.ttf +0 -0
  15. package/docs/assets/fonts/WOFF/OTF/SourceCodePro-Bold.otf.woff +0 -0
  16. package/docs/assets/fonts/WOFF/OTF/SourceCodePro-Regular.otf.woff +0 -0
  17. package/docs/assets/fonts/WOFF/TTF/SourceCodePro-Bold.ttf.woff +0 -0
  18. package/docs/assets/fonts/WOFF/TTF/SourceCodePro-Regular.ttf.woff +0 -0
  19. package/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Bold.otf.woff2 +0 -0
  20. package/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Regular.otf.woff2 +0 -0
  21. package/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Bold.ttf.woff2 +0 -0
  22. package/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2 +0 -0
  23. package/docs/assets/fonts/source-code-pro.css +23 -0
  24. package/docs/assets/github.css +123 -0
  25. package/docs/assets/site.js +168 -0
  26. package/docs/assets/split.css +15 -0
  27. package/docs/assets/split.js +782 -0
  28. package/docs/assets/style.css +147 -0
  29. package/docs/index.html +3636 -0
  30. package/{index.html → docs/playground.html} +48 -12
  31. package/documentation.yml +12 -0
  32. package/lib/terafy.client.js +230 -6
  33. package/lib/terafy.server.js +192 -10
  34. package/package.json +7 -6
  35. package/plugins/vue2.js +173 -0
  36. package/plugins/{vue.js → vue3.js} +20 -11
@@ -20,7 +20,7 @@
20
20
  </style>
21
21
 
22
22
  <script type="module">
23
- import TeraFy from './dist/terafy.js';
23
+ import TeraFy from 'https://esm.run/@iebh/tera-fy';
24
24
 
25
25
  Vue.createApp({
26
26
  data() { return {
@@ -97,6 +97,32 @@
97
97
  </head>
98
98
  <body>
99
99
  <div id="app">
100
+ <!-- Nav header {{{ -->
101
+ <nav class="navbar navbar-expand-lg navbar-light bg-light px-4">
102
+ <a href="https://github.com/IEBH/TERA-fy" class="navbar-brand">TERA-fy</a>
103
+ <button type="button" data-toggle="collapse" data-target="#navbarAreas" class="navbar-toggler">
104
+ <span class="navbar-toggler-icon"></span>
105
+ </button>
106
+ <div id="navbarAreas" class="collapse navbar-collapse">
107
+ <ul class="navbar-nav">
108
+ <li class="navbar-item me-1">
109
+ <a href="https://iebh.github.io/TERA-fy" class="btn btn-light">API docs</a>
110
+ </li>
111
+ <li class="navbar-item mr-1">
112
+ <a href="https://iebh.github.io/TERA-fy/playground.html" class="btn btn-primary text-white">Playground</a>
113
+ </li>
114
+ </ul>
115
+ </div>
116
+ <ul class="navbar-nav">
117
+ <li class="navbar-item">
118
+ <a href="https://github.com/IEBH/TERA-fy" class="btn btn-light">
119
+ GitHub
120
+ </a>
121
+ </li>
122
+ </ul>
123
+ </nav>
124
+ <!-- }}} -->
125
+
100
126
  <div class="container pt-4">
101
127
  <div class="row">
102
128
  <div class="col-sm-12 col-md-6">
@@ -161,12 +187,6 @@
161
187
  <div class="card-header">Projects</div>
162
188
  <div class="card-body">
163
189
  <div class="list-group">
164
- <a
165
- @click="run('bindProject')"
166
- class="list-group-item list-group-item-action disabled"
167
- >
168
- terafy.bindProject()
169
- </a>
170
190
  <a
171
191
  @click="run('getProject')"
172
192
  class="list-group-item list-group-item-action"
@@ -186,7 +206,7 @@
186
206
  >
187
207
  <div>terafy.setActiveProject(</div>
188
208
  <select
189
- v-if="projects"
209
+ v-if="projects && projects.length > 0"
190
210
  v-model="project"
191
211
  class="form-control"
192
212
  placeholder="Select project..."
@@ -231,16 +251,32 @@
231
251
  terafy.getProjectState()
232
252
  </a>
233
253
  <a
234
- @click="run('bindProjectState')"
254
+ @click="run('applyProjectStatePatch')"
235
255
  class="list-group-item list-group-item-action disabled"
236
256
  >
237
- terafy.bindProjectState()
257
+ terafy.applyProjectStatePatch()
238
258
  </a>
239
259
  <a
240
- @click="run('applyProjectStatePatch')"
260
+ @click="run('subscribeProjectState')"
241
261
  class="list-group-item list-group-item-action disabled"
242
262
  >
243
- terafy.applyProjectStatePatch()
263
+ terafy.subscribeProjectState()
264
+ </a>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ <!-- }}} -->
269
+
270
+ <!-- Project Files {{{ -->
271
+ <div class="card mb-2">
272
+ <div class="card-header">Project Files</div>
273
+ <div class="card-body">
274
+ <div class="list-group">
275
+ <a
276
+ @click="run('getProjectFiles')"
277
+ class="list-group-item list-group-item-action"
278
+ >
279
+ terafy.getProjectFiles()
244
280
  </a>
245
281
  </div>
246
282
  </div>
@@ -0,0 +1,12 @@
1
+ toc:
2
+ - name: TeraFy Playground
3
+ description: |
4
+ See [the TeraFy Playground](./playground.html) to expriment with the various TeraFy API calls.
5
+ - name: Data entities
6
+ children:
7
+ - User
8
+ - Project
9
+ - ProjectFile
10
+ - name: TeraFy
11
+ description: |
12
+ API reference for all methods exposed by the TeraFy client library.
@@ -1,3 +1,4 @@
1
+ import {diff, jsonPatchPathConverter as jsPatchConverter} from 'just-diff';
1
2
  import {cloneDeep} from 'lodash-es';
2
3
  import {nanoid} from 'nanoid';
3
4
 
@@ -61,9 +62,12 @@ export default class TeraFy {
61
62
  'bindProject', 'getProject', 'getProjects', 'setActiveProject', 'requireProject', 'selectProject',
62
63
 
63
64
  // Project state
64
- 'getProjectState', 'applyProjectStatePatch',
65
+ 'getProjectState', 'applyProjectStatePatch', 'subscribeProjectState',
65
66
  // bindProjectState() - See below
66
67
 
68
+ // Project files
69
+ 'getProjectFiles',
70
+
67
71
  // Project Libraries
68
72
  'getProjectLibrary', 'setProjectLibrary',
69
73
  ];
@@ -201,6 +205,39 @@ export default class TeraFy {
201
205
 
202
206
  // }}}
203
207
 
208
+ // Project state - createProjectStatePatch(), applyProjectStatePatchLocal() {{{
209
+ /**
210
+ * Create + transmit a new project state patch base on the current and previous states
211
+ * The transmitted patch follows the [JSPatch](http://jsonpatch.com) standard
212
+ * This function accepts an entire projectState instance, computes the delta and transmits that to the server for merging
213
+ *
214
+ * @param {Object} newState The local projectState to accept
215
+ * @param {Object} oldState The previous projectState to examine against
216
+ *
217
+ * @returns {Promise} A promise which will resolve when the operation has completed
218
+ */
219
+ createProjectStatePatch(newState, oldState) {
220
+ let patch = diff(oldState, newState, jsPatchConverter);
221
+ this.debug('INFO', 'Created project patch', {newState, oldState});
222
+ return this.applyProjectStatePatch(patch);
223
+ }
224
+
225
+
226
+ /**
227
+ * Client function which accepts a patch from the server and applies it to local project state
228
+ * The patch should follow the [JSPatch](http://jsonpatch.com) standard
229
+ * This function is expected to be sub-classed by a plugin
230
+ *
231
+ * @param {Array} patch A JSPatch patch to apply
232
+ *
233
+ * @returns {Promise} A promise which will resolve when the operation has completed
234
+ */
235
+ applyProjectStatePatchLocal(patch) {
236
+ this.debug('WARN', 'Accepted project patch', patch, 'but applyProjectStatePatchLocal() has not been sub-classed');
237
+ return Promise.resolve();
238
+ }
239
+ // }}}
240
+
204
241
  // Init - constructor(), init(), inject*() {{{
205
242
 
206
243
  /**
@@ -217,13 +254,16 @@ export default class TeraFy {
217
254
  * Initalize the TERA client singleton
218
255
  * This function can only be called once and will return the existing init() worker Promise if its called againt
219
256
  *
257
+ * @param {Object} [options] Additional options to merge into `settings` via `set`
220
258
  * @returns {Promise<TeraFy>} An eventual promise which will resovle with this terafy instance
221
259
  */
222
- init() {
260
+ init(options) {
261
+ if (options) this.set(options);
223
262
  if (this.init.promise) return this.init.promise; // Aleady been called - return init promise
224
263
 
225
264
  window.addEventListener('message', this.acceptMessage.bind(this));
226
265
 
266
+ const context = this;
227
267
  return this.init.promise = Promise.resolve()
228
268
  .then(()=> this.detectMode())
229
269
  .then(mode => this.settings.mode = mode)
@@ -232,11 +272,12 @@ export default class TeraFy {
232
272
  this.injectComms(),
233
273
  this.injectStylesheet(),
234
274
  this.injectMethods(),
235
-
236
- // Init all plugins
237
- ...this.plugins
238
- .map(plugin => plugin.init()),
239
275
  ]))
276
+ .then(()=> Promise.all( // Init all plugins (with this outer module as the context)
277
+ this.plugins.map(plugin =>
278
+ plugin.init.call(context)
279
+ )
280
+ ))
240
281
  }
241
282
 
242
283
 
@@ -450,6 +491,7 @@ export default class TeraFy {
450
491
  mixin(target, source) {
451
492
  Object.getOwnPropertyNames(Object.getPrototypeOf(source))
452
493
  .filter(prop => !['constructor', 'prototype', 'name'].includes(prop))
494
+ .filter(prop => prop != 'init') // Don't allow plugin init() to override our version - these all get called during init() anyway
453
495
  .forEach((prop) => {
454
496
  Object.defineProperty(
455
497
  target,
@@ -493,4 +535,186 @@ export default class TeraFy {
493
535
  globalThis.document.body.classList.toggle('tera-fy-focus', isFocused === 'toggle' ? undefined : isFocused);
494
536
  }
495
537
  // }}}
538
+
539
+ // Stub documentation carried over from ./terafy.server.js {{{
540
+ /**
541
+ * Return basic server information as a form of validation
542
+ *
543
+ * @function handshake
544
+ * @returns {Promise<Object>} Basic promise result
545
+ * @property {Date} date Server date
546
+ */
547
+
548
+
549
+ /**
550
+ * User / active session within TERA
551
+ *
552
+ * @name User
553
+ * @property {String} id Unique identifier of the user
554
+ * @property {String} email The email address of the current user
555
+ * @property {String} name The provided full name of the user
556
+ * @property {Boolean} isSubscribed Whether the active user has a TERA subscription
557
+ */
558
+
559
+
560
+ /**
561
+ * Fetch the current session user
562
+ *
563
+ * @function getUser
564
+ * @returns {Promise<User>} The current logged in user or null if none
565
+ */
566
+
567
+
568
+ /**
569
+ * Project entry within TERA
570
+ *
571
+ * @name Project
572
+ * @url https://tera-tools.com/help/api/schema
573
+ */
574
+
575
+
576
+ /**
577
+ * Get the currently active project, if any
578
+ *
579
+ * @function getProject
580
+ * @returns {Promise<Project|null>} The currently active project, if any
581
+ */
582
+
583
+
584
+ /**
585
+ * Get a list of projects the current session user has access to
586
+ *
587
+ * @function getProjects
588
+ * @returns {Promise<Array<Project>>} Collection of projects the user has access to
589
+ */
590
+
591
+
592
+ /**
593
+ * Set the currently active project within TERA
594
+ *
595
+ * @function setActiveProject
596
+ * @param {Object|String} project The project to set as active - either the full Project object or its ID
597
+ */
598
+
599
+
600
+ /**
601
+ * Ask the user to select a project from those available - if one isn't already active
602
+ * Note that this function will percist in asking the uesr even if they try to cancel
603
+ *
604
+ * @function requireProject
605
+ * @param {Object} [options] Additional options to mutate behaviour
606
+ * @param {Boolean} [options.autoSetActiveProject=true] After selecting a project set that project as active in TERA
607
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
608
+ * @param {String} [options.noSelectTitle='Select project'] Dialog title when warning the user they need to select something
609
+ * @param {String} [options.noSelectBody='A project needs to be selected to continue'] Dialog body when warning the user they need to select something
610
+ *
611
+ * @returns {Promise<Project>} The active project
612
+ */
613
+
614
+
615
+ /**
616
+ * Prompt the user to select a project from those available
617
+ *
618
+ * @function selectProject
619
+ * @param {Object} [options] Additional options to mutate behaviour
620
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
621
+ * @param {Boolean} [options.allowCancel=true] Advertise cancelling the operation, the dialog can still be cancelled by closing it
622
+ * @param {Boolean} [options.setActive=false] Also set the project as active when selected
623
+ *
624
+ * @returns {Promise<Project>} The active project
625
+ */
626
+
627
+
628
+ /**
629
+ * Return the current, full snapshot state of the active project
630
+ *
631
+ * @function getProjectState
632
+ * @param {Object} [options] Additional options to mutate behaviour
633
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
634
+ * @param {Array<String>} Paths to subscribe to e.g. ['/users/'],
635
+ *
636
+ * @returns {Promise<Object>} The current project state snapshot
637
+ */
638
+
639
+
640
+ /**
641
+ * Apply a computed `just-diff` patch to the current project state
642
+ *
643
+ * @function applyProjectStatePatch
644
+ * @param {Object} Patch to apply
645
+ * @returns {Promise} A promise which resolves when the operation has completed
646
+ */
647
+
648
+
649
+ /**
650
+ * Subscribe to project state changes
651
+ * This will dispatch an RPC call to the source object `applyProjectStatePatchLocal()` function with the patch
652
+ * If the above call fails the subscriber is assumed as dead and unsubscribed from the polling list
653
+ *
654
+ * @function subscribeProjectState
655
+ * @returns {Promise<Function>} A promise which resolves when a subscription has been created, call the resulting function to unsubscribe
656
+ */
657
+
658
+
659
+ /**
660
+ * Data structure for a project file
661
+ *
662
+ * @name ProjectFile
663
+ *
664
+ * @property {String} id A UUID string representing the unique ID of the file
665
+ * @property {String} name Relative name path (can contain prefix directories) for the human readable file name
666
+ * @property {Object} parsedName An object representing meta file parts of a file name
667
+ * @property {String} parsedName.basename The filename + extention (i.e. everything without directory name)
668
+ * @property {String} parsedName.filename The file portion of the name (basename without the extension)
669
+ * @property {String} parsedName.ext The extension portion of the name (always lower case)
670
+ * @property {String} parsedName.dirName The directory path portion of the name
671
+ * @property {Date} created A date representing when the file was created
672
+ * @property {Date} modified A date representing when the file was created
673
+ * @property {Date} accessed A date representing when the file was last accessed
674
+ * @property {Number} size Size, in bytes, of the file
675
+ * @property {String} mime The associated mime type for the file
676
+ */
677
+
678
+
679
+ /**
680
+ * Fetch the files associated with a given project
681
+ *
682
+ * @function getProjectFiles
683
+ * @param {Object} options Options which mutate behaviour
684
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
685
+ * @param {Boolean} [options.meta=true] Pull meta information for each file entity
686
+ *
687
+ * @returns {Promise<ProjectFile>} A collection of project files for the given project
688
+ */
689
+
690
+
691
+ /**
692
+ * Fetch the active projects citation library
693
+ *
694
+ * @function getProjectLibrary
695
+ * @param {String} [path] Optional file path to use, if omitted the contents of `options` are used to guess at a suitable file
696
+ * @param {Object} [options] Additional options to mutate behaviour
697
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
698
+ * @param {Boolean} [options.multiple=false] Allow selection of multiple libraries
699
+ * @param {Function} [options.filter] Optional async file filter, called each time as `(File:ProjectFile)`
700
+ * @param {Function} [options.find] Optional async final stage file filter to reduce all candidates down to one subject file
701
+ * @param {String|Array<String>} [options.hint] Hints to identify the library to select in array order of preference. Generally corresponds to the previous stage - e.g. 'deduped', 'review1', 'review2', 'dedisputed'
702
+ *
703
+ * @returns {Promise<Array<ProjectFile>>} Collection of references for the selected library matching the given hint + filter, this could be a zero length array
704
+ */
705
+
706
+
707
+ /**
708
+ * Save back a projects citation library
709
+ *
710
+ * @function setProjectLibrary
711
+ * @param {Array<RefLibRef>} Collection of references for the selected library
712
+ *
713
+ * @param {Object} [options] Additional options to mutate behaviour
714
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
715
+ * @param {String} [options.hint] Hint to store against the library. Generally corresponds to the current operation being performed - e.g. 'deduped'
716
+ *
717
+ * @returns {Promise} A promise which resolves when the save operation has completed
718
+ */
719
+ // }}}
496
720
  }
@@ -1,4 +1,5 @@
1
- import {cloneDeep} from 'lodash-es';
1
+ import {cloneDeep, set as pathSet} from 'lodash-es';
2
+ import {diffApply, jsonPatchPathConverter as jsPatchConverter} from 'just-diff-apply';
2
3
  import {nanoid} from 'nanoid';
3
4
 
4
5
  /**
@@ -15,10 +16,15 @@ export default class TeraFyServer {
15
16
  *
16
17
  * @type {Object}
17
18
  * @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
19
+ * @property {Number} subscribeTimeout Acceptable timeout period for subscribers to acklowledge a project change event, failing to respond will result in the subscriber being removed from the available subscriber list
18
20
  * @property {String} restrictOrigin URL to restrict communications to
21
+ * @property {String} projectId The project to use as the default reference when calling various APIs
19
22
  */
20
23
  settings = {
24
+ devMode: false,
21
25
  restrictOrigin: '*',
26
+ subscribeTimeout: 2000,
27
+ projectId: null,
22
28
  };
23
29
 
24
30
  // Contexts - createContext(), messageEvent, senderRpc() {{{
@@ -237,7 +243,7 @@ export default class TeraFyServer {
237
243
  $auth.promise(),
238
244
  $subscriptions.promise(),
239
245
  ])
240
- .then(()=> ({
246
+ .then(()=> $auth.user.id ? {
241
247
  id: $auth.user.id,
242
248
  email: $auth.user.email,
243
249
  name: [
@@ -245,7 +251,7 @@ export default class TeraFyServer {
245
251
  $auth.user.family_name,
246
252
  ].filter(Boolean).join(' '),
247
253
  isSubscribed: $subscriptions.isSubscribed,
248
- }))
254
+ } : null)
249
255
  }
250
256
 
251
257
  // }}}
@@ -403,7 +409,7 @@ export default class TeraFyServer {
403
409
 
404
410
  // }}}
405
411
 
406
- // Project State - getProjectState(), applyProjectStatePatch() {{{
412
+ // Project State - getProjectState(), setProjectState(), saveProjectState(), applyProjectStatePatch(), subscribeProjectState() {{{
407
413
 
408
414
  /**
409
415
  * Return the current, full snapshot state of the active project
@@ -427,13 +433,166 @@ export default class TeraFyServer {
427
433
  }
428
434
 
429
435
 
436
+ /**
437
+ * Set a nested value within the project state
438
+ * Paths can be any valid Lodash.set() value such as:
439
+ *
440
+ * - Dotted notation - e.g. `foo.bar.1.baz`
441
+ * - Array path segments e.g. `['foo', 'bar', 1, 'baz']`
442
+ *
443
+ *
444
+ * @param {String|Array<String>} path The sub-path within the project state to set
445
+ * @param {*} value The value to set
446
+ *
447
+ * @param {Object} [options] Additional options to mutate behaviour
448
+ * @param {Boolean} [options.save=true] Save the changes to the server immediately, disable to queue up multiple writes
449
+ * @param {Boolean} [options.sync=false] Wait for the server to acknowledge the write, you almost never need to do this
450
+ *
451
+ * @returns {Promise} A promise which resolves when the operation has synced with the server
452
+ */
453
+ setProjectState(path, value, options) {
454
+ let settings = {
455
+ save: true,
456
+ sync: false,
457
+ ...options,
458
+ };
459
+
460
+ if (!app.service('$projects').active) throw new Error('No active project');
461
+
462
+ pathSet(app.service('$projects').active, path, value)
463
+
464
+ return (
465
+ this.save && this.sync ? this.saveProjectState()
466
+ : this.save ? void this.saveProjectState()
467
+ : (()=> { throw new Error('setProjectState({sync: true, save: false}) makes no sense') })()
468
+ );
469
+ }
470
+
471
+
472
+ /**
473
+ * Force-Save the currently active project state
474
+ *
475
+ * @returns {Promise} A promise which resolves when the operation has completed
476
+ */
477
+ saveProjectState() {
478
+ if (!app.service('$projects').active) throw new Error('No active project');
479
+
480
+ // TODO: Would be nice if we compared against a sanity hash or something before just clobbering
481
+ this.debug('FIXME: Force saving projects is not yet supported - this should occur in realtime anyway');
482
+ return Promise.resolve();
483
+ }
484
+
485
+
430
486
  /**
431
487
  * Apply a computed `just-diff` patch to the current project state
488
+ *
489
+ * @param {Object} Patch to apply
490
+ * @returns {Promise} A promise which resolves when the operation has completed
432
491
  */
433
492
  applyProjectStatePatch(patch) {
434
- this.debug('Applying sever state patch', {patch});
493
+ if (!app.service('$projects').active) throw new Error('No active project to patch');
494
+ this.debug('Applying', patch.length, 'project state patches', {patch});
495
+ diffApply(app.service('$projects').active, patch, jsPatchConverter);
496
+
497
+ return Promise.resolve();
498
+ }
499
+
500
+
501
+ /**
502
+ * Subscribe to project state changes
503
+ * This will dispatch an RPC call to the source object `applyProjectStatePatchLocal()` function with the patch
504
+ * If the above call fails the subscriber is assumed as dead and unsubscribed from the polling list
505
+ *
506
+ * @returns {Promise<Function>} A promise which resolves when a subscription has been created, call the resulting function to unsubscribe
507
+ */
508
+ subscribeProjectState() {
509
+ if (!this.messageEvent) throw new Error('senderRpc() can only be used if given a context from `createContext()`');
510
+
511
+ let subscriber = {
512
+ id: nanoid(),
513
+ origin: this.messageEvent.origin,
514
+ sendPatch: patch => new Promise((resolve, reject) => {
515
+ let senderTimeout = setTimeout(()=> {
516
+ reject(`Timed out sending to project-state subscriber "${subscriber.origin}"`);
517
+ }, this.subscribeTimeout);
518
+
519
+ return this.senderRpc.call(this, 'applyProjectStatePatchLocal', patch)
520
+ .then(()=> {
521
+ clearTimeout(senderTimeout);
522
+ resolve()
523
+ })
524
+ .catch(e => {
525
+ subscriber.unsubscribe();
526
+ reject(`Rejected calling RPC:applyProjectStatePatchLocal() with project-state subscriber "${subscriber.origin}" -`, e)
527
+
528
+ })
529
+ }),
530
+ unsubscribe: ()=> {
531
+ this.debug('Unsubscribing project-state subscriber', subscriber.origin);
532
+ this.projectStateSubscribers = this.projectStateSubscribers.filter(ps => ps.id != subscriber.id);
533
+ },
534
+ };
535
+
536
+ // Append to subscriber list
537
+ this.projectStateSubscribers.push(subscriber)
435
538
  }
436
539
 
540
+
541
+ /**
542
+ * Subscribers to server project state changes
543
+ * @type {Array<Object>}}
544
+ * @property {String} id A unique ID for this subscriber state
545
+ * @property {String} source The human readable source for each subscriber
546
+ * @property {Function} sendPatch Function used to send a patch to a subscriber
547
+ * @property {Function} unsubscribe Function to release the client subscription
548
+ */
549
+ projectStateSubscribers = [];
550
+ // }}}
551
+
552
+ // Project files - getProjectFiles() {{{
553
+
554
+ /**
555
+ * Data structure for a project file
556
+ * @class ProjectFile
557
+ *
558
+ * @property {String} id A UUID string representing the unique ID of the file
559
+ * @property {String} name Relative name path (can contain prefix directories) for the human readable file name
560
+ * @property {Object} parsedName An object representing meta file parts of a file name
561
+ * @property {String} parsedName.basename The filename + extention (i.e. everything without directory name)
562
+ * @property {String} parsedName.filename The file portion of the name (basename without the extension)
563
+ * @property {String} parsedName.ext The extension portion of the name (always lower case)
564
+ * @property {String} parsedName.dirName The directory path portion of the name
565
+ * @property {Date} created A date representing when the file was created
566
+ * @property {Date} modified A date representing when the file was created
567
+ * @property {Date} accessed A date representing when the file was last accessed
568
+ * @property {Number} size Size, in bytes, of the file
569
+ * @property {String} mime The associated mime type for the file
570
+ */
571
+
572
+
573
+ /**
574
+ * Fetch the files associated with a given project
575
+ *
576
+ * @param {Object} options Options which mutate behaviour
577
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
578
+ * @param {Boolean} [options.meta=true] Pull meta information for each file entity
579
+ *
580
+ * @returns {Promise<ProjectFile>} A collection of project files for the given project
581
+ */
582
+ getProjectFiles(options) {
583
+ let settings = {
584
+ autoRequire: true,
585
+ meta: true,
586
+ ...options,
587
+ };
588
+
589
+ return Promise.resolve()
590
+ .then(()=> app.service('$projects').promise())
591
+ .then(()=> settings.autoRequire && this.requireProject())
592
+ .then(project => app.service('$supabase').fileList(`/projects/${project.id}`, {
593
+ meta: settings.meta,
594
+ }))
595
+ }
437
596
  // }}}
438
597
 
439
598
  // Project Libraries - getProjectLibrary(), setProjectLibrary() {{{
@@ -441,24 +600,47 @@ export default class TeraFyServer {
441
600
  /**
442
601
  * Fetch the active projects citation library
443
602
  *
603
+ * @param {String} [path] Optional file path to use, if omitted the contents of `options` are used to guess at a suitable file
444
604
  * @param {Object} [options] Additional options to mutate behaviour
445
605
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
446
606
  * @param {Boolean} [options.multiple=false] Allow selection of multiple libraries
607
+ * @param {Function} [options.filter] Optional async file filter, called each time as `(File:ProjectFile)`
608
+ * @param {Function} [options.find] Optional async final stage file filter to reduce all candidates down to one subject file
447
609
  * @param {String|Array<String>} [options.hint] Hints to identify the library to select in array order of preference. Generally corresponds to the previous stage - e.g. 'deduped', 'review1', 'review2', 'dedisputed'
448
610
  *
449
- * @returns {Promise<Array<RefLibRef>>} Collection of references for the selected library
611
+ * @returns {Promise<Array<ProjectFile>>} Collection of references for the selected library matching the given hint + filter, this could be a zero length array
450
612
  */
451
- getProjectLibrary(options) {
613
+ getProjectLibrary(path, options) {
452
614
  let settings = {
615
+ path,
453
616
  autoRequire: true,
454
617
  multiple: false,
455
618
  hint: null,
456
- ...options,
619
+ filter: file => true,
620
+ find: files => files.at(0),
621
+ ...(typeof path == 'string' ? {path, ...options} : path),
457
622
  };
458
623
 
459
624
  return Promise.resolve()
460
- .then(()=> settings.autoRequire && this.requireProject())
461
- // FIXME: Stub
625
+ .then(()=> {
626
+ if (settings.path) { // Already have a file name picked
627
+ if (!settings.path.startsWith('/')) throw new Error('All file names must start with a forward slash');
628
+ return settings.path;
629
+ } else { // Try to guess the file from the options structure
630
+ return this.getProjectFiles({
631
+ autoRequire: settings.autoRequire,
632
+ })
633
+ .then(files => files.filter(file =>
634
+ settings.filter(file)
635
+ ))
636
+ .then(files => settings.find(files))
637
+ .then(file => file.path)
638
+ }
639
+ })
640
+ .then(filePath => app.service('$supabase').fileGet(filePath, {
641
+ json: true,
642
+ toast: false,
643
+ }))
462
644
  }
463
645
 
464
646