▣🔗 Asset Link - Use-case Drive

Not trying to ignore you here @Farmer-Ed… just lots of great input all around!

This is one I’m going to be implementing very soon for our spring rabbit breeding/management…

Wow, so many cans of worms here! :nerd_face: :sweat_smile: I don’t have much to say on these right now, but I’d be interested to hear more about what the goals/data look like for each of those scenarios/interfaces.

2 Likes

I updated this to include the date field and fix a bug where the scores were being recorded as “count” rather than “value” measures.

Holy cow I missed this! That’s awesome @Symbioquine! A perfect example of using a convention specification to develop a tool that creates comparable data! :smile:

Sheesh we need the ability to tag the “intended convention” of records like I described in https://www.drupal.org/project/farm/issues/3336224 - hopefully we can decide on a direction for that soon, so that tools like this can tag the convention when they create records!

3 Likes

This thread was bound to open a few cans. :smiley:

Happy to discuss anytime, probably need to sit down and iron out the exact goals of some of these myself, but I think they would be standard enough reports for livestock/ pasture management.

I’ll have to have a look at the work already done on pasture scoring too.

Good questions, @Symbioquine :

I ultimately want the text, so if the audio can be transcribed immediately to text (i.e. the way i input most texts of any significant length on my iPhone), that would be preferable.

Depending on what info/ used by whom, it is eitherTrello (kanban) boards, Google docs & sheets, or Notion (the only proper db in the lot) in a few edge cases. So we can say: mass-market Cloud SAS apps, as a rule -all of which have decent API access, as it happens.

As concerns provision of links to related log files, i like the direction this is going w/ @pat … And as to context-sensitive classification of assets, by QR code and/or GeoLocation, i’m thrilled to hear that you’ve got such capability integrated already.

Can’t wait to have this running on my iPhone &sync’d to my farmOS instance at Farmier!

1 Like

Sure

Of course there will be some challenges in grouping the logs in appropriate groups.
Ideally there would be groups for soil disturbance, drilling, weeding etc

In this treeview I tried to add some icons. [1] is an icon.
I put up this in the file browser. The treeview shows data relevant to the current field:

Let’s say I would compare the covercrops for each season. A bit tedious to expand Drilling group on all sesasons.
If I’m already looking at the current drilling group, I could tap on icon [4] and expand drilling group on all seasons.

Icon [1] could expand the current season.

Yea… Thats a problem.
I think of one season as the year the crop is harvested. And all logs referencing the plant asset is relevant.

But if a crop is drilled in autumn and harvested next autumn it gets more complicated.
And some may perhaps have multiple seasons pr year.

To keep it “simple”, Show all unarchived plant assets for the current field as the 1st tree-level.
And inside that, show all relevant log categories.

To complicate it, categories could be implemented.
For example a Crop category, with subcategories Soil disturbance, Weeding, etc…
The user would need to setup what category to use for this, and add appropriate category for each log.
It would give greate flexibility.

bilde bilde

quote=“Symbioquine, post:20, topic:1516”]
As you say, the seasons list could be quite long - perhaps there’d be an argument for allowing seasons to be archived or some other mechanism to only show the most relevant ones
[/quote]
Archiving could be a good solution. It gives the user control.

Hope this clarified rather than bringing the confusion to a higer level :innocent:

Another use-case/plugin idea: the ability to “clock-in” and “clock-out” on individual logs, which saves data to a (forthcoming) time quantity along with the ID of the user.

See Tracking work hours - #6 by mstenta

1 Like

DieselTracking.alink.vue

/alink/diesel-tracking

3 Likes

I have started to create a module for creating harvest logs with how much you harvest. FarmOS-Asset-link-plugins/Plants at main · jorblad/FarmOS-Asset-link-plugins (github.com) Right now I have hardcoded it to work with count and grams but my plan is try to get it to get the measures from farmOS and populating that in the choice list.

Edit: For now I don’t even know if it is possible to get them but I suppose it should be somehow.

1 Like

That’s awesome @jorblad! It looks like it’s already mostly working too :grin:

I’ve created a new topic where we can discuss it in more detail: ▣🔗 Asset Link - Harvest Plugin Fun!

2 Likes

I also created a plugin for filling up seeds with a purchase log so it needs the farm_ledger module to work. FarmOS-Asset-link-plugins/Seeds/AddNewSeedsToInventory.alink.vue at main · jorblad/FarmOS-Asset-link-plugins (github.com) My thought on the next one is to try to recreate the planting quickform where you also can subtract seeds from inventory but might start with a smaller to just use seeds. For the planting I need to be able to create new assets which I dont know if its that easy to do in asset link… and it also feels like a bit more of a project with much information to take get and data from many places that should work together. :sweat_smile:

2 Likes

It’s just the same as creating logs. i.e. Instead of log--observation the type might be asset--plant. Obviously, the attributes/relationships are different, but there shouldn’t be anything especially hard/surprising about it.

If it starts feeling overwhelming, consider reducing the scope… For example, ask yourself; “What is the minimum functionality I need to move my own use-case forward?” That might mean hard-coding some things or reducing generality/flexibility, but if it means you get a return on your time/coding investment sooner, it’s probably worth it.

2 Likes

Great, then I have a project for next week. I will probably see what I can reduce to get something useful that I can add on to later :slight_smile: Otherwise it might feel a bit overwhelming to take it all in one step :sweat_smile:

3 Likes

First version ready, it only creates the planting log and you choose both what seeds should be used and what kind of plant it is you are planting and there are probably lots of things that could be done in a better or more effective way but it feels like a good start :slight_smile:
FarmOS-Asset-link-plugins/Plants/PlantPlantsFromSeed.alink.vue at main · jorblad/FarmOS-Asset-link-plugins (github.com)

Edit: I have also added that it prefills species based on the asset and you can create new season, seed asset and/or species on the fly.
Edit 2: Also added possibility to upload a picture to the asset and fixed so that it won’t let you submit unless all mandatory fields are have something in them.

3 Likes

This is great, I’m glad to see some more complex use-cases coming together!

Just a few bits of feedback.

fetchAssetLink

I’m curious where this pattern came from? I don’t think you should ever need to do this…

// Create a function to fetch assetLink and store it in the ref
const fetchAssetLink = async () => {
  assetLink.value = await getAssetLink(); // Replace getAssetLink with your code to retrieve assetLink
};

// Fetch the assetLink on component mount
onMounted(fetchAssetLink);

The recommended ways to get an AssetLink instance are:

In a setup method/block;

const assetLink = inject('assetLink');

Otherwise, assetLink should be already available - such as in a plugin’s onLoad method.

findUnitTerm

I don’t think you should need to do this “double-lookup-else-create” pattern;

    const findUnitTerm = async entitySource => {
      const results = await entitySource.query(q => q
          .findRecords('taxonomy_term--unit')
          .filter({ attribute: 'name', op: 'equal', value: UNIT_NAME }));
      return results.flatMap(l => l).find(a => a);
    };

    let seedUnitTerm = await findUnitTerm(assetLink.entitySource.cache);

    if (!seedUnitTerm) {
      seedUnitTerm = await findUnitTerm(assetLink.entitySource);
    }

    if (!seedUnitTerm) {
      const unitTermToCreate = {
          type: 'taxonomy_term--unit',
          id: uuidv4(),
          attributes: {
            name: UNIT_NAME,
          },
      };

      seedUnitTerm = await assetLink.entitySource.update(
          (t) => t.addRecord(unitTermToCreate),
          {label: `Add '${UNIT_NAME}' unit`});
    }

See my feedback on the other thread about the " $relateByName directive". (Actually, it looks like maybe you’re already doing that and just need to delete the above code?)

Retrieving Entity Lists

Looking at the code like this;

  const results = await entitySource.query((q) =>
    q.findRecords('taxonomy_term--plant_type')
  );

  const plant_types = results.flatMap((l) => l);

I’m skeptical that this has been tested… I didn’t try running it, but I’m pretty sure that if you define a single query (instead of a list of them) in the entitySource.query lambda, then it will return a single list of entity results, not a list of lists. Thus the flatMap wouldn’t be necessary and would probably fail.

It’s also worth noting that entitySource.query doesn’t handle pagination automagically, so it could be surprising to users of your plugin to find that only some of the plant types show up (or are filterable) via the drop-down.

It might be better to use the EntitySelect component that Asset Link already provides as part of its plugin API: Asset Link docs EntitySelect (If we examine its code, we find that it doesn’t do pagination since showing a huge list isn’t very helpful, but instead it searches the entity source again if the user types in filter criteria.)

Getting Seeds by Name

It’s probably obvious, but code like the following would only work if your use-case has a very strong convention of seeds having unique names.

const seed = await assetLink.entitySource.query((q) =>
  q.findRecords('asset--seed').filter({ attribute: 'name', op: 'equal', value: seedAsset })
);
console.log('Seed object', seed)

It might also be better to pass the whole seed object through and use the object that your form already had access to instead of looking it up again.

Namespacing

In your other plugin here, I notice you’ve used the id net.symbioquine.farmos_asset_link.actions.v0.harvestPlant.

While it doesn’t hurt anything to put any text you want into that id, it is preferable to base the id on a domain that you control. This is a pattern that I established so that in the future Asset Link can be smarter about showing where functionality comes from and allowing plugins to be gracefully upgraded.

1 Like

I just wanted to share a fun example of how the plugin extension model in Asset Link can potentially enable rapid prototyping without dropping into a full dev environment…

I needed to do some graphing of data in farmOS. I could make a module for farmOS itself, or a built plugin that incorporates whatever charting JS I picked, but I wanted to experiment with some different libraries without spinning up those things.

Instead, I wanted to try just pulling in a library from a CDN to test with. Using Asset Link’s library functionality, we can create one plugin that exposes the ability to fetch and inject the CDN resources (with a little caching) directly and another plugin to pull in Chart.js and render one of the examples:

ChartTest.alink.vue

<script setup>
const fetchInject = await import('plugin-library:fetch-inject');
import { onMounted, ref } from "vue";

const chartCanvas = ref(null);

onMounted(async () => {
  await fetchInject([
    'https://cdn.jsdelivr.net/npm/chart.js'
  ]);

  new Chart(chartCanvas.value, {
    type: 'bar',
    data: {
      labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
      datasets: [{
        label: '# of Votes',
        data: [12, 19, 3, 5, 2, 3],
        borderWidth: 1
      }]
    },
    options: {
      scales: {
        y: {
          beginAtZero: true
        }
      }
    }
  });
});
</script>

<template alink-route[com.example.farmos_asset_link.routes.v0.chart_test]="/chart-test">
<canvas ref="chartCanvas"></canvas>
</template>

fetchInject.lib.alink.js

import { Buffer } from 'buffer/';

export default class fetchInjectLib {
  static onLoad(handle, assetLink) {
    /*! Fetch Inject | Copyright (C) VHS <vhsdev@tutanota.com> | @license Zlib */
    const injector=function(i,n,j,e,c,t,s){t=n.createElement(j);j==="template"?(t.innerHTML=e.text,n.head.appendChild(t.content.cloneNode(!0)),c(e)):(s=n.getElementsByTagName(j)[0],t.appendChild(n.createTextNode(e.text)),t.onload=c(e),s?s.parentNode.insertBefore(t,s):n.head.appendChild(t))}; // prettier-ignore

    const fetchInject = async (inputs, promise, { fetch } = globalThis) => {
        const resources = [];
        const deferreds = promise ? [].concat(promise) : [];
        const thenables = [];
        inputs.forEach((input) =>
            deferreds.push(
                fetch(input)
                    .then((res) => Promise.all([res.clone().text(), res.blob()]))
                    .then(async ([text, blob]) => {
                        resources.push({ text, blob });
                    })
            )
        );
        await Promise.all(deferreds);
        resources.forEach((resource) =>
            thenables.push({
                then: (resolve) => {
                    let type = 'script';
                    const mimeToElementType = new Map([
                        ['text/html', 'template'],
                        ['text/css', 'style'],
                        ['application/javascript', 'script']
                    ]);
                    for (const [mime, elementType] of mimeToElementType) {
                        if (resource.blob.type.startsWith(mime)) {
                            type = elementType;
                            break;
                        }
                    }
                    typeof document !== 'undefined'
                        ? injector(globalThis, document, type, resource, resolve)
                        : resolve(resource);
                }
            })
        );
        return await Promise.all(thenables);
    };

    const cachedFetchInject = async (inputs, promise, { fetch } = globalThis) => {
      const decoratedFetch = fetch;

      /**
       * Decorate fetch requests to cache successful results - ideally allowing offline function even for CDN resources.
       */
      const cachedFetch = async (url, opts) => {
        const options = opts || {};

        if (!options.method) options.method = 'GET';

        if (options.method != 'GET') {
			return await decoratedFetch(url, opts);
        }
        
        const cacheKey = `fetch-inject-cached-res:${url}`;
        
        const cacheItem = await assetLink.store.getItem(cacheKey);

        const CACHED_DATA_URL_PREFIX = "data:application/octet-stream;base64,";
        
        if (cacheItem && 0) {
			const responseBody = Buffer.from(cacheItem.value.substring(CACHED_DATA_URL_PREFIX.length), 'base64');

          	return new Response(responseBody, { status: 200, statusText: "OK" });
        }

		const response = await decoratedFetch(url, opts);

        if (response.ok) {
          	const responseBody = await response.clone().arrayBuffer();

			await assetLink.store.setItem(cacheKey, { value: CACHED_DATA_URL_PREFIX + Buffer.from(responseBody).toString('base64') });
        }

        return response;
      };

      return await fetchInject(inputs, promise, { fetch: cachedFetch });
    };
    
    handle.provideLibrary('fetch-inject', library => {
      library.version('3.3.1');
      library.provides(cachedFetchInject);
    });
  }

}

And with Plotly;

ChartTest2.alink.vue

<script setup>
const fetchInject = await import('plugin-library:fetch-inject');
import { onMounted, ref } from "vue";

const chartDiv = ref(null);

onMounted(async () => {
  await fetchInject([
    'https://cdn.plot.ly/plotly-3.4.0.min.js'
  ]);

  Plotly.newPlot(chartDiv.value, [{
      x: [1, 2, 3, 4, 5],
      y: [1, 2, 4, 8, 16] }], {
      margin: { t: 0 } } );
});
</script>

<template alink-route[com.example.farmos_asset_link.routes.v0.chart_test2]="/chart-test2">
<div ref="chartDiv"></div>
</template>
2 Likes

Here is a use-case idea:

I often create observation logs while I’m walking around (quick photo and notes about an asset). It would be nice to have an “Add GPS coordinate” action button for logs, which simply records your current location as a point in the log’s geometry field.

There are probably some technical considerations with this to make it feasible…

For one, simply turning on the GPS and asking for a location when the button is clicked probably won’t work. The first read of a GPS is always incorrect, and it takes a little while for it to improve its accuracy. This is just the nature of the way GPS works when you turn it on - it needs to receive signals from satellites and figure out where it is based on time signatures. The longer it’s on, the more accurate it gets.

So in order for an “Add GPS coordinate” button to be useful (accurate), I think Asset Link would need to turn on the GPS in advance so that it’s accuracy can improve for as long as possible before pressing the button. Obviously this has both battery and privacy considerations to it, so this sort of “background” tracking would need to be optional. Perhaps something similar to the “Precaching” toggle?

The obvious next thought is: showing the point on a map in Asset Link, so you can confirm that it’s accurate (and maybe change it if necessary)… :slight_smile:

1 Like

Just started sketching out a toolbar button plugin that would track location and potentially expose that to other plugins: WIP: Example Asset Link Geolocation Toolbar Button · GitHub

2 Likes

For fun, I just tried running one of the JSCAD demos:

Note: If you want to try running this, you need the fetchInject plugin from several posts above.

<script setup>
const fetchInject = await import('plugin-library:fetch-inject');
import { onMounted, ref } from "vue";

const jscadDiv = ref(null);

onMounted(async () => {
  await fetchInject([
    'https://unpkg.com/@jscad/modeling@2.6.1',
    'https://cdn.jsdelivr.net/gh/jscad/OpenJSCAD.org@%40jscad%2Fweb%402.6.1/packages/utils/regl-renderer/dist/jscad-regl-renderer.min.js',
  ]);

  // Copied/modified from https://github.com/jscad/OpenJSCAD.org/blob/f245ea3a5072024b789f276c6fb9ce6c3eb3fd0d/packages/utils/regl-renderer/demo.html
  
  // ********************
  // The design to render.
  // ********************
  const { booleans, colors, primitives } = jscadModeling // modeling comes from the included MODELING library

  const { intersect, subtract } = booleans
  const { colorize } = colors
  const { cube, cuboid, line, sphere, star } = primitives

  const demo = (parameters) => {
    const logo = [
      colorize([1.0, 0.4, 1.0], subtract(
        cube({ size: 300 }),
        sphere({ radius: 200 })
      )),
      colorize([1.0, 1.0, 0], intersect(
        sphere({ radius: 130 }),
        cube({ size: 210 })
      ))
    ]

    const transpCube = colorize([1, 0, 0, 0.75], cuboid({ size: [100 * parameters.scale, 100, 210 + (200 * parameters.scale)] }))
    const star2D = star({ vertices: 8, innerRadius: 300, outerRadius: 400 })
    const line2D = colorize([1.0, 0, 0], line([[260, 260], [-260, 260], [-260, -260], [260, -260], [260, 260]]))

    // some colors are intentionally without alpha channel to test geom2ToGeometries will add alpha channel
    const colorChange = [
      [1, 0, 0, 1],
      [1, 0.5, 0],
      [1, 0, 1],
      [0, 1, 0],
      [0, 0, 0.7]
    ]
    star2D.sides.forEach((side, i) => {
      if (i >= 2) side.color = colorChange[i % colorChange.length]
    })

    return [transpCube, star2D, line2D, ...logo]
  }

  // ********************
  // Renderer configuration and initiation.
  // ********************
  const { prepareRender, drawCommands, cameras, controls, entitiesFromSolids } = jscadReglRenderer

  const perspectiveCamera = cameras.perspective
  const orbitControls = controls.orbit

  const containerElement = jscadDiv.value;

  const width = containerElement.clientWidth
  const height = containerElement.clientHeight

  const state = {}

  // prepare the camera
  state.camera = Object.assign({}, perspectiveCamera.defaults)
  perspectiveCamera.setProjection(state.camera, state.camera, { width, height })
  perspectiveCamera.update(state.camera, state.camera)

  // prepare the controls
  state.controls = orbitControls.defaults

  // prepare the renderer
  const setupOptions = {
    glOptions: { container: containerElement },
  }
  const renderer = prepareRender(setupOptions)

  const gridOptions = {
    visuals: {
      drawCmd: 'drawGrid',
      show: true
    },
    size: [500, 500],
    ticks: [25, 5],
    // color: [0, 0, 1, 1],
    // subColor: [0, 0, 1, 0.5]
  }

  const axisOptions = {
    visuals: {
      drawCmd: 'drawAxis',
      show: true
    },
    size: 300,
    // alwaysVisible: false,
    // xColor: [0, 0, 1, 1],
    // yColor: [1, 0, 1, 1],
    // zColor: [0, 0, 0, 1]
  }

  const entities = entitiesFromSolids({}, demo({ scale: 1 }))

  // assemble the options for rendering
  const renderOptions = {
    camera: state.camera,
    drawCommands: {
      drawAxis: drawCommands.drawAxis,
      drawGrid: drawCommands.drawGrid,
      drawLines: drawCommands.drawLines,
      drawMesh: drawCommands.drawMesh
    },
    // define the visual content
    entities: [
      gridOptions,
      axisOptions,
      ...entities
    ]
  }

  // the heart of rendering, as themes, controls, etc change
  let updateView = true

  const doRotatePanZoom = () => {

    if (rotateDelta[0] || rotateDelta[1]) {
      const updated = orbitControls.rotate({ controls: state.controls, camera: state.camera, speed: rotateSpeed }, rotateDelta)
      state.controls = { ...state.controls, ...updated.controls }
      updateView = true
      rotateDelta = [0, 0]
    }

    if (panDelta[0] || panDelta[1]) {
      const updated = orbitControls.pan({ controls:state.controls, camera:state.camera, speed: panSpeed }, panDelta)
      state.controls = { ...state.controls, ...updated.controls }
      panDelta = [0, 0]
      state.camera.position = updated.camera.position
      state.camera.target = updated.camera.target
      updateView = true
    }

    if (zoomDelta) {
      const updated = orbitControls.zoom({ controls:state.controls, camera:state.camera, speed: zoomSpeed }, zoomDelta)
      state.controls = { ...state.controls, ...updated.controls }
      zoomDelta = 0
      updateView = true
    }
  }

  const updateAndRender = (timestamp) => {
    doRotatePanZoom()

    if (updateView) {
      const updates = orbitControls.update({ controls: state.controls, camera: state.camera })
      state.controls = { ...state.controls, ...updates.controls }
      updateView = state.controls.changed // for elasticity in rotate / zoom

      state.camera.position = updates.camera.position
      perspectiveCamera.update(state.camera)

      renderer(renderOptions)
    }
    window.requestAnimationFrame(updateAndRender)
  }
  window.requestAnimationFrame(updateAndRender)

  // convert HTML events (mouse movement) to viewer changes
  let lastX = 0
  let lastY = 0

  const rotateSpeed = 0.002
  const panSpeed = 1
  const zoomSpeed = 0.08
  let rotateDelta = [0, 0]
  let panDelta = [0, 0]
  let zoomDelta = 0
  let pointerDown = false

  const moveHandler = (ev) => {
    if(!pointerDown) return
    const dx = lastX - ev.pageX 
    const dy = ev.pageY - lastY 

    const shiftKey = (ev.shiftKey === true) || (ev.touches && ev.touches.length > 2)
    if (shiftKey) {
      panDelta[0] += dx
      panDelta[1] += dy
    } else {
      rotateDelta[0] -= dx
      rotateDelta[1] -= dy
    }

    lastX = ev.pageX
    lastY = ev.pageY

    ev.preventDefault()
  }
  const downHandler = (ev) => {
    pointerDown = true
    lastX = ev.pageX
    lastY = ev.pageY
    containerElement.setPointerCapture(ev.pointerId)
  }

  const upHandler = (ev) => {
    pointerDown = false
    containerElement.releasePointerCapture(ev.pointerId)
  }

  const wheelHandler = (ev) => {
    zoomDelta += ev.deltaY
    ev.preventDefault()
  }

  containerElement.onpointermove = moveHandler
  containerElement.onpointerdown = downHandler
  containerElement.onpointerup = upHandler
  containerElement.onwheel = wheelHandler
});
</script>

<template alink-route[com.example.farmos_asset_link.routes.v0.jscad_test]="/jscad-test">
<div ref="jscadDiv" id="jscad"></div>
</template>

<style>
#jscad {
  width: 15cm;
  height: 15cm;
  margin: 0;
  outline: 1px solid black;
}
</style>
1 Like

I’m finding myself opening up Asset Link a lot these days - it’s so helpful for quick in-the-field entries!

One plugin that I find myself wishing for often is just a simple “Add log” action on assets. I’m imagining something very simple - fields for log type, name, timestamp, notes, and photos. Perhaps it could reuse the existing plugin for editing logs?

I know we discussed “Quick Observations” earlier… this would serve that need as well, but also other log types.

1 Like