Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat #879: Different embedding styles prep #1273

Merged
merged 20 commits into from
Aug 23, 2023

Conversation

badsketch
Copy link
Contributor

Addresses #879
This PR does not change functionality. It's mainly implementation changes under the hood in preparation for when we do add other embedding styles.

raw

preview

I tried to keep in mind that we want to make this extensible for future variations of style and content scoping, so I tried to use a strategy design pattern where we have extractors and formatters. Hoping it's not overkill and not too hard to read. 😅OOP isn't my strong suit, but I had fun! Hoping for feedback.

Is this what you had in mind? Could you also clarify what you think would be the correct rolloutstrategy? I noticed if I add a deprecationMessage to preview.embedNoteInContainer then it disappears from the settings UI unless you explicitly have it set. At the moment, the new preview.embedStyle doesn't do anything. Wondering at what point should I hot swap the two.
If we're going to maintain both at the same time, (before preview.embedNoteInContainer gets removed), which setting should take precedence?

@@ -47,25 +47,20 @@ export const markdownItWikilinkEmbed = (
let content = `Embed for [[${wikilink}]]`;
switch (includedNote.type) {
case 'note': {
let noteText = readFileSync(includedNote.uri.toFsPath()).toString();
const section = Resource.findSection(
const noteStyle = getFoamVsCodeConfig(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the next phase, I'd update the regex to capture the new syntax. Then here we'd have logic to determine noteStyle and noteContent. For now, noteStyle pulls from the existing config setting, and there is no noteContent, it just defaults to "full".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, although from the configuration we should be getting both the style and the content, is that planned for this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I'm a little fuzzy on release strategy. The new preview.embedStyle isn't used anywhere at the moment, but I can change it so that this wikilink-embed reads it and it overrides preview.embedNoteInContainer if both are set. I can also add a deprecation flag to preview.embedNoteInContainer so it no longer appears in settings UI. In the next PR, I can remove it altogether package.json as well as this file. wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release strategy you outlined is what I had in mind and I think works pretty well.

I am not aware of a built-in way in VS Code to do that, did you come across something or had something in mind?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's great, I was already thinking of a small utility to deal with deprecation, but that won't be necessary 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweet!

Copy link
Collaborator

@riccardoferretti riccardoferretti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a stab at this!
For what regards the design, in Foam we tend to be more on the functional side than OOP (with blurred lines, especially because VS Code API is mostly OO).
I like the separation of the extraction from the rendering.
For me an interface with a single method is a strong candidate for being replaced by a function type (e.g. see FoamFeature interface, it used to be an object, in the end I decided that a simple fn was enough for all the use I needed for it, and made the code more readable).

I left some comments, let me know your thoughts


class InlineFormatter implements EmbedNoteFormatter {
format(content: string) {
return content;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you still wanna render here right? this.md.render(content)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the switch statement for note, attachment, and image all return content and outside the switch statement content gets rendered here https://github.com/foambubble/foam/pull/1273/files#diff-42b32f07212d469a3715b0a74b0f67ff192e592d0724fcfce358130debe8dcfaL84

It does feels weird for InlineFormatter to be doing nothing, but the Extractor kind of already does everything and I couldn't find something in

  extract(note: Resource) {
    let noteText = readFileSync(note.uri.toFsPath()).toString();
    const section = Resource.findSection(note, note.uri.fragment);
    if (isSome(section)) {
      const rows = noteText.split('\n');
      noteText = rows
        .slice(section.range.start.line, section.range.end.line)
        .join('\n');
    }
    noteText = withLinksRelativeToWorkspaceRoot(
      noteText,
      this.parser,
      this.workspace
    );
    return noteText;   // already "formatted"
  }
}

that could belong in formatter.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CardFormatter renders the md, so I feel the behavior should be consistent: do we expect the formatter to return an html string or a md string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the issue is that card style needs an additionalmd.render(content) step.
Existing code returns an md string:

  content = getFoamVsCodeConfig(CONFIG_EMBED_NOTE_IN_CONTAINER)
    ? `<div class="embed-container-note">${md.render(noteText)}</div>`
    : noteText;

So we can have both return an html string:

function cardFormatter(content: string, md: markdownit): string {
  return md.render(
    `<div class="embed-container-note">${md.render(content)}</div>`
  );
}

function inlineFormatter(content: string, md: markdownit): string {
  return md.render(content);
}

or we can have both return an md string

function cardFormatter(content: string, md: markdownit): string {
    return `<div class="embed-container-note">${md.render(content)}</div>`
}

function inlineFormatter(content: string, md: markdownit): string {
  return content;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see what you mean, I had forgotten about the double rendering.
I don't have a strong preference between the two options, so long as we are consistent, happy to go with what you prefer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, I decided to go with producing an html and having the rest of the cases like embedding images or attachments also produce the html as well

@@ -47,25 +47,20 @@ export const markdownItWikilinkEmbed = (
let content = `Embed for [[${wikilink}]]`;
switch (includedNote.type) {
case 'note': {
let noteText = readFileSync(includedNote.uri.toFsPath()).toString();
const section = Resource.findSection(
const noteStyle = getFoamVsCodeConfig(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, although from the configuration we should be getting both the style and the content, is that planned for this PR?

)
? 'card'
: 'inline';
const noteEmbedder = new NoteEmbedder(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if NoteEmbedder helps encapsulate a behavior, and compare that with the additional "complexity" of the indirection - what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little confused by "encapsulate a behavior". Is that a type of implementation pattern?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no no, what I mean is basically that currently in NoteEmbedder lives the code that was in this function (that's what I mean by "encapsulating": we don't see the actual code and rely on this object to "do the work").
This indirection removes complexity by removing some code here (and putting it in NoteEmbedder) but also adds complexity because understanding the code requires an extra step (that is, check the implementation of NoteEmbedder).
My original comment was whether what's gained offsets what's lost, I wonder if we could in fact remove the NoteEmbedder and simply have the code here, wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, I see what you mean. That's a good point. For e05c1c2 I changed the interfaces to function types and replaced all the classes with functions. Is this what you had in mind?

I feel like the class-based approach would be preferable after we add more variations, so maybe we can go with the functional approach and reconsider it as an option in the future? I say that because with the functional approach, it seems dependencies get added for all formatters and extractors. Like if extractor A requires parser: ResourceParser, but extractor B doesn't, the type still needs to include it, and extractor B has to have it in its signature:

export type EmbedNoteExtractor = (
  note: Resource,
  parser: ResourceParser,
) => string;

function extractorA(note: Resource, parser: resourceParser) {}
function extractorB(note: Resource, parser: resourceParser) {} // doesn't need the parser, but generateNoteEmbedding() expects a type EmbedNoteExtractor 

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about moving the two lines of generateNoteEmbedding in the caller and remove the fn altogether?

Your comment about the dependencies is correct. And in some of my functions this situation happens. For example in the feature activate function I used as example before, sometimes the Foam object is not required, but I pass it anyways.
Depending on the number of dependencies and how related they are to the method, I am ok with the compromise.

In this specific case of the extractor and the formatter, so far the parameters being passed IMO read well with the function signature, so I don't see yellow/red flags.
This is something that can be reassesed in the future shall the need arise.


As an aside, the functional way of mimicking the constructor would be using a factory, e.g.

function createExtractor(extraDep: any) : (note: Resource, parser: resourceParser) => string {
  return (note: Resource, parser: resourceParser) => {
    // do something with all extraDep, note and parser
  }
}

extractorC = createExtractor(extra)

extractorC(note, parser)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion, done! Also really appreciate the snippet

@badsketch
Copy link
Contributor Author

For me an interface with a single method is a strong candidate for being replaced by a function type (e.g. see FoamFeature interface, it used to be an object, in the end I decided that a simple fn was enough for all the use I needed for it, and made the code more readable).

Great idea! I think it'll make it a lot more succinct

@badsketch
Copy link
Contributor Author

For me an interface with a single method is a strong candidate for being replaced by a function type (e.g. see FoamFeature interface, it used to be an object, in the end I decided that a simple fn was enough for all the use I needed for it, and made the code more readable).

Great idea! I think it'll make it a lot more succinct

Hmm, gave this shot, but I realized that with a function type I don't have a way to enforce classes to implement that function. And NoteEmbedder expects the interfaces to be defined so that it can use polymorphism to extract() and format().

class NoteEmbedder {
  extractor: EmbedNoteExtractor; // can't be a function type
  formatter: EmbedNoteFormatter; // can't be a function type
.
.
 generateEmbedding () {
  content = extractor.extract(stuff)
  return formatter.format(content)
}

Could I be missing something?

@riccardoferretti
Copy link
Collaborator

I think I replied to all your questions, let me know if the part around the embedder is still unclear though!

@badsketch
Copy link
Contributor Author

badsketch commented Aug 20, 2023

Appreciate your patience in working through this with me! I think the code/design is in a good spot based on what we discussed 👍

Lastly:
I decided to change preview.embedStyle to preview.embedNoteType since in the code I use "style" to refer to the format type (inline, card), and the naming was starting to get confusing to me if the setting has the word "style" in it. I thought it might be a clearer separation of identity for our variables if scope = full/content, style = inline/card, and embedNoteType as the combination of the two. Thoughts?

Is it possible for you to retrigger the CI /Build and Test? It's failing on wikilink-embed.spec.ts, but it succeeds on my machine. Also, I'm noticing refactor.spec.ts fail on my machine, but succeed in CI. Have you noticed this before along with a lot of listener failed errors? Or might I have messed something up 😓

@badsketch badsketch marked this pull request as ready for review August 20, 2023 22:48
Copy link
Collaborator

@riccardoferretti riccardoferretti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it looks really good!
I left a couple of minor comments, but we are there.

Comment on lines 62 to 69
let formatter: EmbedNoteFormatter = cardFormatter;
switch (noteStyle) {
case 'card':
formatter = cardFormatter;
break;
case 'inline':
formatter = inlineFormatter;
break;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a more condensed way to achieve this (we use it in other places in the Foam codebase):

const formatter = noteStyle === 'card'
  ? cardFormatter
  : noteStyle === ' inline'
  ? inlineFormatter
  : cardFormatter // your default

It's purely a matter of style, for me the advantage is that it allows to use const (a bummer that js/ts don't have a functional switch statement), it's a bit more clear that the conditions are just about initiating the variable, and that the conditions can a bit more flexible. I think it takes a sec getting used to, but it flows once the eye is trained :)

For consistency with the codebase I would lean towards using the pattern for both extractor and formatter.

see link-completion.ts:188 and generate-link-references.ts:68

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all for consistency! I can't help but unravel ternary operators in my head, which make chained ones seem complex, but you're right, they start looking just like an if/else block in time. Updated!

@riccardoferretti
Copy link
Collaborator

I re-triggered the tests, it looks like something is getting escaped in the html, so I reckon it will fail again.

(re the refactor tests, yes sometimes they fail for me too locally, haven't figured out how to remove those spurious fails)

@badsketch
Copy link
Contributor Author

badsketch commented Aug 22, 2023

You might be able to tell from the commit history I've somewhat lost it 😓. I've just been getting the most bewildering results in terms of reproducibility, which makes it really frustrating to debug, and it requires I push to really see if my changes are effective. All e2e tests pass for Windows and Mac OS X for me, but as you metnioned, CI keeps failing due to the escaped html tags.

I'm starting to suspect it's the async nature of wikilink-embed.spec.ts and they way it sets up the dependencies(?), but knowing my luck it could just be another red herring. Might take a bit longer as I try to fix this. If it's alright with you, I might create a dummy branch with cherry-picked commits then create a dummy PR to see if I can reproduce it with Github CI. Let me know if I missed something obvious!

@riccardoferretti
Copy link
Collaborator

I took a look at the tests and it seems like they are using the card formatter instead of the inline.
And I wonder if the problem is in retrieveNoteConfig, probably we shouldn't give priority to the old setting CONFIG_EMBED_NOTE_IN_CONTAINER.

(2m later - I did a quick test locally by commenting the overwrite and the tests passed)

@badsketch
Copy link
Contributor Author

Yeah! Based on what you said, I think I got it figured out. First mistake was setting the wrong config name 4190c43, and secondly, I needed to initialize CONFIG_EMBED_NOTE_IN_CONTAINER, by wrapping the tests in an additional withModifiedFoamConfiguration() otherwise all had the setting set to true, and the flag has higher precedence in retrieveNoteConfig()

Should finally be in a good review state. Again, thanks for the troubleshooting help! Looking forward to finally doing the implementation 😅


// **DEPRECATED** setting to be removed
// for now it overrides the above to preserve user settings if they have it set
if (getFoamVsCodeConfig<boolean>(CONFIG_EMBED_NOTE_IN_CONTAINER, false)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From this I assume that if someone has both settings, the old setting will overwrite the new one?
I think that the new setting should always have the precedence, and we should only read the previous one if the new setting has not been used, wdyt?

Copy link
Collaborator

@riccardoferretti riccardoferretti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, I don't think that comment is very important, as we are planning to sunset the old config pretty quickly, so all looks good to me!

@riccardoferretti riccardoferretti merged commit 6c1d686 into foambubble:master Aug 23, 2023
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants