import {createStore} from 'vuex'; import router from '@/router'; import FallBackResolver from "@/dns"; import NeighborsCache from "@/neigbors"; import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation"; import {parseIdentityRecord, serializeIdentityRecord} from "@/identity"; import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js"; //import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin"; const defaultPreferenceDefinitions = [ { key: 'ui.compact_mode', type: 'boolean', default: false, label: 'Compact mode', description: 'Show denser item rows and reduce spacing in lists.' }, { key: 'ui.default_search_scope', type: 'enum', options: ['inventory', 'friends', 'all'], default: 'inventory', label: 'Default search scope', description: 'Choose where the global search starts.' }, { key: 'notifications.desktop_enabled', type: 'boolean', default: true, label: 'Desktop notifications', description: 'Enable in-browser notifications for important updates.' }, { key: 'files.max_upload_mb', type: 'integer', default: 25, label: 'Default upload size limit (MB)', description: 'Used as a prefill hint in upload dialogs.' }, { key: 'ui.experimental_flags', type: 'json', default: {}, label: 'Experimental flags', description: 'Optional JSON toggles for feature previews.' }, ] const parseStoredPreferences = (storageKey) => { try { const raw = localStorage.getItem(storageKey) if (!raw) { return {} } const parsed = JSON.parse(raw) return parsed && typeof parsed === 'object' ? parsed : {} } catch (_error) { return {} } } export default createStore({ state: { local_loaded: false, last_load: {}, user: null, user_profile: null, token: null, keypair: null, remember: false, friends: [], friendProfiles: {}, item_map: {}, home_servers: null, all_friends_servers: null, messages: [], resolver: new FallBackResolver(), unreachable_neighbors: new NeighborsCache(), tags: [], properties: [], files: [], categories: [], availability_policies: [], domains: [], storage_locations: [], active_workflows: [], preferenceDefinitions: [], accountPreferences: {}, devicePreferences: {}, preferencesLoaded: false, }, mutations: { setInventoryItems(state, {url, items}) { state.item_map[url] = items; }, setFriends(state, friends) { state.friends = friends; }, setFriendProfile(state, {username, profile}) { state.friendProfiles = {...state.friendProfiles, [username]: profile}; }, setFriendProfiles(state, profiles) { state.friendProfiles = profiles; }, setHomeServers(state, home_servers) { state.home_servers = home_servers; }, setAllFriendsServers(state, servers) { state.all_friends_servers = servers; }, setTags(state, tags) { state.tags = tags; }, setProperties(state, properties) { state.properties = properties; }, setCategories(state, categories) { state.categories = categories; }, setAvailabilityPolicies(state, availability_policies) { state.availability_policies = availability_policies; }, setDomains(state, domains) { state.domains = domains; }, setStorageLocations(state, storage_locations) { state.storage_locations = storage_locations; }, setFiles(state, files) { state.files = files; }, setActiveWorkflows(state, workflows) { state.active_workflows = workflows; }, setPreferenceDefinitions(state, definitions) { state.preferenceDefinitions = definitions; }, setAccountPreferences(state, preferences) { state.accountPreferences = preferences; }, setDevicePreferences(state, preferences) { state.devicePreferences = preferences; }, setAccountPreference(state, {key, value}) { state.accountPreferences = {...state.accountPreferences, [key]: value}; }, setDevicePreference(state, {key, value}) { state.devicePreferences = {...state.devicePreferences, [key]: value}; }, deleteAccountPreference(state, key) { const prefs = {...state.accountPreferences}; delete prefs[key]; state.accountPreferences = prefs; }, deleteDevicePreference(state, key) { const prefs = {...state.devicePreferences}; delete prefs[key]; state.devicePreferences = prefs; }, setPreferencesLoaded(state, loaded) { state.preferencesLoaded = loaded; }, setUser(state, user) { state.user = user; if (state.remember) localStorage.setItem('user', user); }, setUserProfile(state, profile) { state.user_profile = profile; }, setToken(state, token) { state.token = token; if (state.remember) localStorage.setItem('token', token); }, setKey(state, keypair) { state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair)) if (state.remember) localStorage.setItem('keypair', nacl.to_hex(state.keypair.signSk).slice(0, 64)) }, setRemember(state, remember) { state.remember = remember; if (!remember) { localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); } localStorage.setItem('remember', remember); }, setUserFromIdentityRecord(state, {user, keypair}) { try { localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); state.user = user; state.keypair = keypair; if (state.remember) { localStorage.setItem('user', user); localStorage.setItem('keypair', key) } } catch (e) { console.error('Invalid identity record') } }, logout(state) { state.user = null; state.user_profile = null; state.token = null; state.keypair = null; state.accountPreferences = {}; state.preferenceDefinitions = []; state.preferencesLoaded = false; // Note: devicePreferences are NOT cleared on logout (device-specific) localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); router.push('/login'); }, load_local(state) { if (state.local_loaded) return; const remember = localStorage.getItem('remember'); const user = localStorage.getItem('user'); const token = localStorage.getItem('token'); const keypair = localStorage.getItem('keypair'); if (user && token) { this.commit('setUser', user); this.commit('setToken', token); if (keypair) { this.commit('setKey', keypair) } router.push('/'); } state.cache_loaded = true; } }, actions: { async loadUserPreferences({state, commit, dispatch, getters}) { if (state.preferencesLoaded) return const deviceKey = 'toolshed.preferences.device' commit('setDevicePreferences', parseStoredPreferences(deviceKey)) let definitions = defaultPreferenceDefinitions let accountPreferences = {} try { const servers = await dispatch('getHomeServers') definitions = await servers.get(getters.nullAuth, '/auth/preferences/') || defaultPreferenceDefinitions accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {} } catch (error) { console.error('Failed to load user preferences:', error) } commit('setPreferenceDefinitions', definitions) commit('setAccountPreferences', accountPreferences) commit('setPreferencesLoaded', true) }, async setAccountPreference({state, commit, dispatch, getters}, {key, value}) { const servers = await dispatch('getHomeServers') await servers.put(getters.signAuth, '/auth/self/preferences/', {[key]: value}) commit('setAccountPreference', {key, value}) }, async resetAccountPreference({state, commit, dispatch, getters}, key) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/auth/self/preferences/' + key + '/') commit('deleteAccountPreference', key) }, async setDevicePreference({state, commit}, {key, value}) { commit('setDevicePreference', {key, value}) localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) }, async resetDevicePreference({state, commit}, key) { commit('deleteDevicePreference', key) localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) }, async login({commit, dispatch, state, getters}, {username, password, remember}) { commit('setRemember', remember); const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) .then(set => set.post(getters.nullAuth, '/auth/token/', {username, password})) if (data.token && data.key) { commit('setToken', data.token); commit('setUser', username); commit('setKey', data.key); const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) commit('setHomeServers', s) await dispatch('fetchUserProfile', {force: true}) return true; } else { return false; } }, async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) { if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) { return state.user_profile } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/auth/user/') commit('setUserProfile', data) state.last_load.user_profile = Date.now() return data }, async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) { const servers = await dispatch('getHomeServers') const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null} const data = await servers.patch(getters.signAuth, '/auth/user/', payload) commit('setUserProfile', data) state.last_load.user_profile = Date.now() if (data.profile_picture) { state.last_load.files = 0 } return data }, async lookupServer({state}, {username}) { const domain = username.split('@')[1] const request = '_toolshed-server._tcp.' + domain + '.' return await state.resolver.query(request, 'SRV').then( (result) => result.map( // Must match what the browser actually puts in the Host header for // the request this gets used to build (federation.js always signs // and fetches "https://" + server + target) - it omits a :443 for // the default HTTPS port, so keeping it here would make every // signature check on the receiving end fail against the real request. (answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port)) }, async getHomeServers({state, dispatch, commit}) { if (state.home_servers) return state.home_servers const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) commit('setHomeServers', promise) return promise }, async getAllKnownServers({state, dispatch, commit}) { const friends = await dispatch('fetchFriends') if (state.all_friends_servers) return state.all_friends_servers const promise = (async () => { const servers = new ServerSetUnion([]) const home = await dispatch('getHomeServers') servers.add(home) for (const friend of friends) { try { const s = await dispatch('lookupServer', {username: friend.username}) servers.add(new ServerSet(s, state.unreachable_neighbors)) } catch (e) { // Don't let a single unresolvable/unreachable friend abort the whole // search/federation lookup - just skip them and continue. console.error('could not resolve server for friend', friend.username, e) } } return servers })().catch(e => { // Don't cache a permanently-rejected promise - allow a retry next time. state.all_friends_servers = null throw e }) commit('setAllFriendsServers', promise) return promise }, async getFriendServers({state, dispatch, commit}, {username}) { return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) }, async fetchBackendVersion({dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.nullAuth, '/api/version/') }, async fetchInventoryItems({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const items = await servers.get(getters.signAuth, '/api/inventory_items/') items.map(item => item.files.map(file => file.owner = item.owner)) commit('setInventoryItems', {url: '/', items}) return items }, async createInventoryItem({state, dispatch, getters}, item) { const servers = await dispatch('getHomeServers') const data = {availability_policy: 'private', ...item} const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data) state.last_load.files = 0 return reply }, async updateInventoryItem({state, dispatch, getters}, item) { const servers = await dispatch('getHomeServers') const data = {availability_policy: 'friends', ...item} data.files = data.files.map(file => file.id) return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data) }, async deleteInventoryItem({state, dispatch, getters}, item) { const servers = await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/') dispatch('fetchInventoryItems') return ret }, async fetchSearchResults({state, commit, dispatch, getters}, {query}) { const servers = await dispatch('getAllKnownServers') const items = await servers.get(getters.signAuth, '/api/search/?query=' + query) items.map(item => item.files.map(file => file.owner = item.owner)) commit('setInventoryItems', {url: '/search/' + query, items}) return items }, async fetchFriends({commit, dispatch, getters, state}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/friends/') commit('setFriends', data) return data }, async fetchFriendProfile({commit, dispatch, getters}, {username}) { try { const servers = await dispatch('getFriendServers', {username}); // username here is already a full handle (username@domain), see FriendSerializer const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/'); if (profile) { commit('setFriendProfile', {username, profile}); } return profile; } catch (error) { console.error(`Failed to fetch profile for ${username}:`, error); return null; } }, async fetchForeignItem({dispatch, getters}, {owner, id}) { try { const servers = await dispatch('getFriendServers', {username: owner}); // owner here is a full handle (username@domain) - see the /api/inventory_items/// // endpoint (toolshed/api/inventory.py get_shared_item), which looks the item up by owner rather // than by requester, unlike the plain /api/inventory_items/ list/detail endpoints. const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/'); if (item && item.files) { item.files.forEach(file => file.owner = item.owner) } return item; } catch (error) { console.error(`Failed to fetch item ${id} for ${owner}:`, error); return null; } }, async fetchFriendRequests({state, dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/friendrequests/') }, async requestFriend({state, dispatch, getters}, {username}) { if (username in state.friends) { return true; } const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: username }) if (home_reply.status !== 'pending' || !home_reply.secret) return false; const befriendee_servers = await dispatch('getFriendServers', {username}) const ext_reply = await befriendee_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: username, befriender_key: nacl.to_hex(state.keypair.signPk), secret: home_reply.secret }) return true; }, async acceptFriend({state, dispatch, getters}, {id, secret, befriender}) { const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post(getters.signAuth, '/api/friends/', { friend_request_id: id, secret: secret }) const ext_servers = await dispatch('getFriendServers', {username: befriender}) const ext_reply = await ext_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: befriender, befriender_key: nacl.to_hex(state.keypair.signPk), secret: secret }) return true }, async declineFriend({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friendrequests/' + id + '/') }, async dropFriend({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, async fetchFiles({state, commit, dispatch, getters}) { if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) { return state.files } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/files/') data.map(file => file.owner = state.user) commit('setFiles', data) state.last_load.files = Date.now() return data }, async fetchFilesByItem({state, commit, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/item_files/' + id + '/') }, async pushFile({state, dispatch, getters}, {item_id, file}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', file) if (data.name) { data.owner = state.user state.files.push(data) return data } }, async deleteFile({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/files/' + id + '/') state.files = state.files.filter(file => file.id !== id) }, async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/') state.files = state.files.filter(file => file.id !== file_id) }, async stageFile({state, dispatch, getters}, {lifetime_id, file}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file) if (data.hash) { return data.hash } }, async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/') }, async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash}) if (data.name) { data.owner = state.user state.files.push(data) return data } }, async fetchTags({state, commit, dispatch, getters}) { if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) { return state.tags } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/tags/') commit('setTags', data) state.last_load.tags = Date.now() return data }, async fetchProperties({state, commit, dispatch, getters}) { if (state.last_load.properties > Date.now() - 1000 * 60 * 60 * 24) { return state.properties } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/properties/') commit('setProperties', data) state.last_load.properties = Date.now() return data }, async fetchCategories({state, commit, dispatch, getters}) { if (state.last_load.categories > Date.now() - 1000 * 60 * 60 * 24) { return state.categories } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/categories/') commit('setCategories', data) state.last_load.categories = Date.now() return data }, async fetchAvailabilityPolicies({state, commit, dispatch, getters}) { if (state.last_load.availability_policies > Date.now() - 1000 * 60 * 60 * 24) { return state.availability_policies } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/availability_policies/') commit('setAvailabilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]}))) state.last_load.availability_policies = Date.now() return data }, async fetchStorageLocations({state, commit, dispatch, getters}) { if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) { return state.storage_locations } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/storage_locations/') commit('setStorageLocations', data) state.last_load.storage_locations = Date.now() return data }, async deleteStorageLocation({state, dispatch, getters}, location) { const servers = await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/') dispatch('fetchStorageLocations') return ret }, async createStorageLocation({state, dispatch, getters}, location) { const servers = await dispatch('getHomeServers') const data = {...location} if (data.parent === '') data.parent = null const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data) state.last_load.storage_locations = 0 return reply }, async updateStorageLocation({state, dispatch, getters}, location) { const servers = await dispatch('getHomeServers') const data = {...location} if (data.parent === '') data.parent = null const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data) dispatch('fetchStorageLocations') return reply }, async fetchInfo({state, commit, dispatch, getters}) { const last_load_info = Math.min( state.last_load.tags, state.last_load.properties, state.last_load.categories, state.last_load.availability_policies) if (last_load_info > Date.now() - 1000 * 60 * 60 * 24) { return state.info } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/info/') commit('setTags', data.tags) commit('setProperties', data.properties) commit('setCategories', data.categories) commit('setAvailabilityPolicies', data.availability_policies.map(policy => ({ slug: policy[0], text: policy[1] }))) commit('setDomains', data.domains) state.last_load.tags = Date.now() state.last_load.properties = Date.now() state.last_load.categories = Date.now() state.last_load.availability_policies = Date.now() return data }, async userIdentityRecord({state}, {password}) { return await serializeIdentityRecord(state.user, state.keypair, password); }, async fetchActiveWorkflows({state, commit, dispatch, getters}) { if (state.last_load.active_workflows > Date.now() - 1000 * 60 * 5) { // Cache for 5 minutes return state.active_workflows } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/workflows/') const workflows = data.map(deserializeWorkflowPayload) commit('setActiveWorkflows', workflows) state.last_load.active_workflows = Date.now() return workflows }, async createWorkflow({state, commit, dispatch, getters}, workflowData) { const servers = await dispatch('getHomeServers') // The backend stores `payload` as an opaque string - the frontend is // responsible for serializing/deserializing the JSON itself. const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData)) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async updateWorkflow({state, commit, dispatch, getters}, workflow) { const servers = await dispatch('getHomeServers') const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow)) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', { current_step: currentStep, payload: JSON.stringify(payload) }) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async deleteWorkflow({state, commit, dispatch, getters}, workflowId) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/workflows/' + workflowId + '/') state.last_load.active_workflows = 0 // Invalidate cache return true } }, getters: { isLoggedIn(state) { if (!state.local_loaded) { state.remember = localStorage.getItem('remember') === 'true' state.user = localStorage.getItem('user') state.token = localStorage.getItem('token') const keypair = localStorage.getItem('keypair') if (keypair) state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair)) state.local_loaded = true } return state.user !== null && (state.token !== null || state.keypair !== null) }, allUserProfiles(state) { // Combine user's own profile with all friend profiles const combined = {...state.friendProfiles}; if (state.user && state.user_profile) { combined[state.user] = state.user_profile; } return combined; }, signAuth(state) { return createSignAuth(state.user, state.keypair.signSk) }, tokenAuth(state) { return createTokenAuth(state.token) }, nullAuth(state) { return createNullAuth({}) }, inventory_items(state) { return state.item_map['/'] || [] }, loaded_items(state) { return Object.entries(state.item_map).reduce((acc, [url, items]) => { return acc.concat(items) }, []) }, notifications(state) { // supported types: error, warning, info, login, success, friend const u = state.unreachable_neighbors.list().map(elem => { return { type: 'error', title: elem.domain + ' unreachable', msg: 'The neighbor ' + elem.domain + ' is currently unreachable. Please try again later.', time: elem.time } }) return [...u, { type: 'info', title: 'Welcome to the Toolshed', msg: 'This is a federated social network. You can add friends from other servers and share items with them.', time: Date.now() }, { type: 'warning', title: 'Lorem ipsum', msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.', time: Date.now() - 1000 * 60 * 60 * 2 }, { type: 'login', title: 'Login from 192.186.1.8', time: Date.now() - 1000 * 60 * 60 * 5 }, { type: 'friend', title: 'New connection', msg: 'Christina accepted your request.', time: Date.now() - 1000 * 60 * 60 * 14 }, { type: 'success', title: 'Lorem ipsum', msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.', time: Date.now() - 1000 * 60 * 60 * 24 }] }, getPreference: (state) => (key, fallbackDefault = null) => { if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) { return state.devicePreferences[key] } if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) { return state.accountPreferences[key] } const definition = state.preferenceDefinitions.find((pref) => pref.key === key) if (definition) { return definition.default } return fallbackDefault }, /** * Extracts the human-readable name from a fully qualified handle. * Handles look like "git:tools#tag:drill" or "git:base#property:length". * If the given value does not look like a handle, it is returned unchanged. */ getNameFromHandle: () => (handle) => { if (typeof handle !== 'string') { return handle; } const match = handle.match(/#(?:tag|property):(.+)$/); return match ? match[1] : handle; }, } })