@@ -442,7 +407,7 @@ export default {
display: flex;
}
- &-type, &-id {
+ &-type {
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -461,37 +426,6 @@ export default {
flex-grow: 2;
flex-basis: 50%;
}
- &-id {
- flex-grow: 0;
- text-align: right;
- text-transform: none;
- color: @grey-very-dark-transparent;
- background-color: @badge-color-transparent;
- transition: background-color 0.5s ease;
- letter-spacing: 0.5px;
- font-size: 1.2rem;
- font-weight: 400;
- padding: 0 0.75em;
- border-radius: 1em;
-
- &.recently-copied {
- background-color: @brand-success;
- color: @white;
- }
- }
- &-idCopyIcon {
- transition: all 0.25s ease;
- margin: 0 0.25em 0 -0.25em;
- overflow: hidden;
- width: 1.2em;
- opacity: 1;
- cursor: pointer;
- &.collapsedIcon {
- margin: 0 0 0 0;
- width: 0;
- opacity: 0;
- }
- }
&-sourceLabel {
border: 1px solid;
diff --git a/vue-client/src/components/shared/id-pill.vue b/vue-client/src/components/shared/id-pill.vue
new file mode 100644
index 000000000..0c5f4c138
--- /dev/null
+++ b/vue-client/src/components/shared/id-pill.vue
@@ -0,0 +1,110 @@
+
+
+
+
+ {{ idAsFnurgel }}
+
+
+
+
diff --git a/vue-client/src/resources/json/i18n.json b/vue-client/src/resources/json/i18n.json
index f6bd03038..2cdb71a98 100644
--- a/vue-client/src/resources/json/i18n.json
+++ b/vue-client/src/resources/json/i18n.json
@@ -128,6 +128,8 @@
"Save and stop editing": "Spara och sluta redigera",
"Create record": "Skapa post",
"Remove": "Ta bort",
+ "Remove entity": "Ta bort entitet",
+ "Remove link to": "Ta bort länk till",
"Search" : "Sök",
"Searching": "Söker",
"E-mail": "E-post",
@@ -242,12 +244,13 @@
"Link was created and title was copied from instance": "Länkning påbörjades och titel kopierades från instans",
"Linking was successful": "Länkning lyckades",
"Extract entity": "Bryt ut entitet",
+ "Extract": "Bryt ut",
"Create/link": "Skapa/länka",
"Cancel linking": "Avbryt utbrytning",
"Yes, start linking": "Ja, påbörja utbrytning",
"Do you want to create linkable": "Vill du skapa länkbart",
"The local entity will be extracted and linked": "Den lokala entiteten bryts ut och länkas",
- "The work is extracted once the instance is saved": "Verket bryts ut när instansen sparas",
+ "Extracted when the record is saved": "Bryts ut när posten sparas",
"Yes, create": "Ja, skapa",
"Create": "Skapa",
"Creating link": "Skapar länk",
diff --git a/vue-client/src/settings.js b/vue-client/src/settings.js
index 96f83f792..ed69ff1e2 100644
--- a/vue-client/src/settings.js
+++ b/vue-client/src/settings.js
@@ -31,6 +31,9 @@ export default {
// 'Concept', - Blocking this per request of MSS
'Work',
],
+ extractableMappedTypes: {
+ Item: 'SingleItem',
+ },
showTypeChangerFor: [
'Instance',
'Work',
diff --git a/vue-client/src/store.js b/vue-client/src/store.js
index e5edb8795..929f322a4 100644
--- a/vue-client/src/store.js
+++ b/vue-client/src/store.js
@@ -8,6 +8,7 @@ import * as User from '@/models/user';
import settings from './settings';
const EXTRACT_ON_SAVE = '__EXTRACT_ON_SAVE__';
+export const DELETE_ON_SAVE = '__DELETE_ON_SAVE__';
const store = createStore({
state: {
@@ -73,6 +74,7 @@ const store = createStore({
changeHistory: [],
event: [],
magicShelfMarks: [],
+ otherRecordsToDeleteOnSave: [],
extractItemsOnSave: [],
},
status: {
@@ -193,6 +195,12 @@ const store = createStore({
if (payload.addToHistory) {
const changes = [];
each(payload.changeList, (node) => {
+ if (node.path === DELETE_ON_SAVE) {
+ state.inspector.otherRecordsToDeleteOnSave.push({ id: node.id, type: node.type });
+ changes.push(node);
+ return;
+ }
+
let oldValue;
if (node.path === '') {
oldValue = inspectorData;
@@ -273,6 +281,7 @@ const store = createStore({
},
flushChangeHistory(state) {
state.inspector.changeHistory = [];
+ state.inspector.otherRecordsToDeleteOnSave = [];
},
setChangeHistory(state, data) {
state.inspector.changeHistory = data;
@@ -731,6 +740,12 @@ const store = createStore({
dispatch('removeExtractItemOnSave', { path: node.path.replace(`.${EXTRACT_ON_SAVE}`, '') });
}
+ if (node.path === DELETE_ON_SAVE) {
+ state.inspector.otherRecordsToDeleteOnSave = state.inspector.otherRecordsToDeleteOnSave
+ .filter((i) => i.id !== node.id);
+ return acc;
+ }
+
// It had a value
if (typeof node.value !== 'undefined') {
return [...acc, node];
@@ -754,7 +769,7 @@ const store = createStore({
}
return acc;
- }, [])
+ }, []);
commit("updateInspectorData", {
addToHistory: false,
diff --git a/vue-client/src/utils/data.js b/vue-client/src/utils/data.js
index c60af9c93..7c55f3eb9 100644
--- a/vue-client/src/utils/data.js
+++ b/vue-client/src/utils/data.js
@@ -245,3 +245,7 @@ export function translateAliasedUri(uri) {
}
return translatedUri;
}
+
+export function isLink(o) {
+ return Object.keys(o).length === 1 && Object.keys(o).includes('@id');
+}
diff --git a/vue-client/src/utils/http.js b/vue-client/src/utils/http.js
index bb8819480..a14ca83a9 100644
--- a/vue-client/src/utils/http.js
+++ b/vue-client/src/utils/http.js
@@ -135,6 +135,8 @@ export function getRelatedRecords(queryPairs, apiPath) {
export async function getDocument(uri, contentType = 'application/ld+json', embellished = true) {
let translatedUri = translateAliasedUri(uri);
+ translatedUri = translatedUri.split('#')[0];
+
if (!uri.includes('embellished=')) {
const query = `${uri.includes('?') ? '&' : '?'}embellished=${embellished}`;
translatedUri = `${translatedUri}${query}`;
@@ -159,6 +161,7 @@ export async function getDocument(uri, contentType = 'application/ld+json', embe
}
responseObject.data = await response.json();
responseObject.ETag = response.headers.get('ETag');
+ responseObject.uri = uri;
return responseObject;
}
@@ -206,3 +209,9 @@ export function getResourceFromCache(url) {
});
});
}
+
+export function fetchPlainEtags(ids) {
+ return Promise
+ .all(ids.map((id) => getDocument(id, undefined, false)))
+ .then((responses) => Object.fromEntries(responses.map((r) => [r.uri, r.ETag])));
+}
diff --git a/vue-client/src/utils/record.js b/vue-client/src/utils/record.js
index b13790abc..7e4b49c7c 100644
--- a/vue-client/src/utils/record.js
+++ b/vue-client/src/utils/record.js
@@ -3,6 +3,7 @@ import * as LxlDataUtil from 'lxljs/data';
import * as VocabUtil from 'lxljs/vocab';
import * as httpUtil from './http';
import * as DataUtil from './data';
+import { isLink } from './data';
export function getRecordId(data, quoted) {
const recordObj = recordObject(data, quoted);
@@ -282,12 +283,15 @@ export function getObjectAsRecord(mainEntity, record = {}) {
return newObj;
}
-export function getCleanedExtractedData(extractedData, inspectorData, resources) {
+export function getCleanedExtractedData(extractedData, inspectorData, resources, settings) {
/**
* Cleans extracted data and adds title from parent data if it is missing.
*/
const cleanObj = DataUtil.removeNullValues(extractedData);
if (cleanObj == null) return null; // Nothing left of this
+ if (settings.extractableMappedTypes[extractedData['@type']]) {
+ cleanObj['@type'] = settings.extractableMappedTypes[extractedData['@type']];
+ }
if (VocabUtil.isSubClassOf(extractedData['@type'], 'Work', resources.vocabClasses, resources.context)) {
// Entity is of type Work or derived type
if (extractedData.hasOwnProperty('hasTitle') === false) {
@@ -394,3 +398,55 @@ export function getNewCopy(id) {
});
});
}
+
+// FIXME check in vocab. Need a precomputed category => members map?
+// eslint-disable-next-line no-unused-vars
+function compositionalProperties(resources) {
+ return ['hasComponent'];
+}
+
+export function moveFromQuotedToMain(splitData, ids, resources) {
+ // FIXME check whole doc
+ compositionalProperties(resources).forEach((p) => {
+ if (splitData.mainEntity[p]) {
+ splitData.mainEntity[p].forEach((o) => {
+ if (isLink(o) && ids.includes(o['@id'])) {
+ Object.assign(o, splitData.quoted[o['@id']]);
+ delete splitData.quoted[o['@id']];
+ }
+ });
+ }
+ });
+}
+
+export function extractInlinedData(data, ids, resources) {
+ // FIXME check whole doc
+ const result = {};
+ compositionalProperties(resources).forEach((p) => {
+ const v = data[p] || [];
+ v.forEach((o, ix) => {
+ if (o['@id'] && !isLink(o) && ids.includes(o['@id'])) {
+ result[o['@id']] = o;
+ v[ix] = { '@id': o['@id'] };
+ }
+ });
+ });
+
+ return result;
+}
+
+export function getLinkedIdsToBeInlined(splitData, resources) {
+ // FIXME check whole doc
+ const result = [];
+ compositionalProperties(resources).forEach((p) => {
+ if (splitData.mainEntity[p]) {
+ result.push(
+ ...splitData.mainEntity[p]
+ .filter((o) => isLink(o))
+ .map((o) => o['@id']),
+ );
+ }
+ });
+
+ return result;
+}
diff --git a/vue-client/src/utils/shelfmark.js b/vue-client/src/utils/shelfmark.js
index 83c5b666b..be209a978 100644
--- a/vue-client/src/utils/shelfmark.js
+++ b/vue-client/src/utils/shelfmark.js
@@ -1,6 +1,7 @@
import { get } from 'lodash-es';
import md5 from 'md5';
import * as HttpUtil from './http';
+import { isSubClassOf } from "lxljs/vocab";
export async function hasAutomaticShelfControlNumber(shelfMarkId) {
return HttpUtil.get({
@@ -9,11 +10,12 @@ export async function hasAutomaticShelfControlNumber(shelfMarkId) {
}).then((shelfMark) => Promise.resolve(shelfMark['@graph'][1].hasOwnProperty('nextShelfControlNumber')));
}
-export async function checkAutoShelfControlNumber(obj, settings, user) {
+export async function checkAutoShelfControlNumber(obj, settings, user, resources) {
const mainEntity = obj['@graph'][1];
+ const isItem = (type) => type && isSubClassOf(type, 'Item', resources.vocab, resources.context);
- if (mainEntity['@type'] === 'Item') {
- const items = (mainEntity.hasComponent || []).filter((c) => c['@type'] === 'Item');
+ if (isItem(mainEntity['@type'])) {
+ const items = (mainEntity.hasComponent || []).filter((c) => isItem(c['@type']));
items.push(mainEntity);
for (const item of items) {
// we actually want to do these sequentially in case they link to the same shelf mark
diff --git a/vue-client/src/views/Inspector.vue b/vue-client/src/views/Inspector.vue
index 71dbb5414..3a3efdc36 100644
--- a/vue-client/src/views/Inspector.vue
+++ b/vue-client/src/views/Inspector.vue
@@ -70,8 +70,9 @@ export default {
return {
documentId: null,
documentETag: null,
+ etagMap: {},
+ inlinedIds: [],
documentTitle: null,
- result: {},
recordLoaded: false,
modalOpen: false,
removeInProgress: false,
@@ -207,8 +208,18 @@ export default {
});
}).then((result) => {
if (typeof result !== 'undefined') {
- this.result = result;
const splitFetched = LxlDataUtil.splitJson(result);
+
+ this.inlinedIds = RecordUtil.getLinkedIdsToBeInlined(splitFetched, this.resources);
+ if (this.inlinedIds.length > 0) {
+ HttpUtil.fetchPlainEtags(this.inlinedIds).then((etagMap) => {
+ console.log('Inlined document etags:', etagMap);
+ this.etagMap = etagMap;
+ });
+
+ RecordUtil.moveFromQuotedToMain(splitFetched, this.inlinedIds, this.resources);
+ }
+
this.$store.dispatch('setInspectorData', splitFetched);
this.onRecordLoaded();
}
@@ -216,6 +227,8 @@ export default {
},
initializeRecord() {
this.documentETag = null; // Reset this
+ this.etagMap = {};
+ this.inlinedIds = [];
this.marcPreview.active = false;
this.$store.dispatch('pushLoadingIndicator', 'Loading document');
this.recordLoaded = false;
@@ -423,6 +436,26 @@ export default {
}
});
},
+ doRemoveOtherRecord(fnurgel, type) {
+ const url = `${this.settings.apiPath}/${fnurgel}`;
+ HttpUtil._delete({ url, activeSigel: this.user.settings.activeSigel, token: this.user.token }).then(() => {
+ this.$store.dispatch('pushNotification', {
+ type: 'success',
+ message: `${labelByLang(type)} ${translatePhrase('was deleted')}!`,
+ });
+ }, (error) => {
+ if (error.status === 403) {
+ this.$store.dispatch('pushNotification', { type: 'danger', message: `${translatePhrase('Forbidden')} - ${translatePhrase('This entity may have active links')} - ${error.statusText}` });
+ } else {
+ this.$store.dispatch('pushNotification', { type: 'danger', message: `${translatePhrase('Something went wrong')} - ${error.statusText}` });
+ }
+ });
+ },
+ removeOtherRecords() {
+ this.inspector.otherRecordsToDeleteOnSave.forEach((r) => {
+ this.doRemoveOtherRecord(RecordUtil.extractFnurgel(r.id), r.type);
+ });
+ },
loadDocument() {
this.$store.dispatch('setInspectorStatusValue', { property: 'isNew', value: false });
@@ -651,6 +684,7 @@ export default {
await this.saveExtracted();
this.saveQueued = () => this.saveItem(done);
} catch (error) {
+ console.error(error);
this.$store.dispatch('pushNotification', {
type: 'danger',
message: `${StringUtil.getUiPhraseByLang('Something went wrong', this.user.settings.language, this.resources.i18n)} - ${error}`,
@@ -661,18 +695,18 @@ export default {
async saveExtracted() {
this.$store.dispatch('setInspectorStatusValue', { property: 'saving', value: true });
for await (const path of this.inspector.extractItemsOnSave) {
- const cleanedExtractedData = RecordUtil.getCleanedExtractedData(get(this.inspector.data, path), this.inspector.data, this.resources);
+ const cleanedExtractedData = RecordUtil.getCleanedExtractedData(get(this.inspector.data, path), this.inspector.data, this.resources, this.settings);
const extractedRecord = RecordUtil.getObjectAsRecord(cleanedExtractedData, {
descriptionCreator: { '@id': this.user.getActiveLibraryUri() },
...((this.inspector.data.record['@id'] !== 'https://id.kb.se/TEMPID') && {
derivedFrom: { '@id': this.inspector.data.record['@id'] },
}),
});
- const response = await HttpUtil.post({
+ const response = await this.preSaveHook(extractedRecord).then((r) => HttpUtil.post({
url: `${this.settings.apiPath}/data`,
token: this.user.token,
activeSigel: this.user.settings.activeSigel,
- }, extractedRecord);
+ }, r));
const postUrl = `${response.getResponseHeader('Location')}`;
const savedExtractedRecord = await HttpUtil.get({ url: `${postUrl}/data.jsonld`, contentType: 'text/plain' });
const savedExtractedMainEntity = LxlDataUtil.splitJson({
@@ -737,10 +771,12 @@ export default {
? 'was sent'
: (!this.documentId ? 'was created' : 'was saved');
+ const type = get(obj, ['@graph', 1, '@type'], '');
+
setTimeout(() => {
this.$store.dispatch('pushNotification', {
type: 'success',
- message: `${labelByLang(this.recordType)} ${StringUtil.getUiPhraseByLang(msgKey, this.user.settings.language, this.resources.i18n)}!`,
+ message: `${labelByLang(type)} ${StringUtil.getUiPhraseByLang(msgKey, this.user.settings.language, this.resources.i18n)}!`,
});
}, 10);
if (!this.documentId) {
@@ -752,6 +788,7 @@ export default {
} else {
this.fetchDocument();
this.warnOnSave();
+ this.removeOtherRecords();
if (done) {
this.stopEditing();
} else {
@@ -790,6 +827,7 @@ export default {
} });
break;
default:
+ console.error(error);
errorMessage = `${StringUtil.getUiPhraseByLang('Something went wrong', this.user.settings.language, this.resources.i18n)} - ${error.status}: ${StringUtil.getUiPhraseByLang(error.statusText, this.user.settings.language, this.resources.i18n)}`;
this.$store.dispatch('pushNotification', { type: 'danger', message: `${errorBase}. ${errorMessage}.` });
}
@@ -821,9 +859,45 @@ export default {
}, 5000);
},
async preSaveHook(obj) {
- await checkAutoShelfControlNumber(obj, this.settings, this.user);
+ await checkAutoShelfControlNumber(obj, this.settings, this.user, this.resources);
+ await this.saveInlined(obj);
+
return obj;
},
+ async saveInlined(obj) {
+ if (this.inlinedIds.includes(obj['@graph'][1]['@id'])) {
+ // we only handle one level of inlined docs so far
+ return;
+ }
+
+ const inlined = RecordUtil.extractInlinedData(obj['@graph'][1], this.inlinedIds, this.resources);
+ await Promise.all(Object.keys(inlined)
+ .map((id) => HttpUtil.getDocument(id, undefined, false)
+ .then((r) => {
+ if (!r.data) {
+ // TODO Fix HttpUtil.getDocument so that it errors instead
+ const e = new Error(`Could not fetch document ${id}`);
+ e.statusText = 'Try again';
+ throw e;
+ }
+ if (r.ETag !== this.etagMap[id]) {
+ const msg = 'The resource has been modified by another user';
+ const e = new Error(`${msg} ${id}`);
+ // FIXME smuggling the fnurgel inside status
+ e.status = RecordUtil.extractFnurgel(id);
+ e.statusText = msg;
+ throw e;
+ }
+ r.data['@graph'][1] = inlined[id];
+ return this.preSaveHook(r.data);
+ })
+ .then((data) => HttpUtil.put({
+ url: id,
+ ETag: this.etagMap[id],
+ activeSigel: this.user.settings.activeSigel,
+ token: this.user.token,
+ }, data))));
+ },
},
watch: {
'inspector.data'(val, oldVal) {