first commit

This commit is contained in:
andreas 2024-12-30 18:47:28 +01:00
commit 3ff66b9176
16 changed files with 2870 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

25
README.md Normal file
View file

@ -0,0 +1,25 @@
# Cooksync Official
This is the official Obsidian plugin for [Cooksync](https://cooksync.app), maintained by the Cooksync team. It enables automatic import of recipe data from your Cooksync account. Note that this plugin requires a [Cooksync](https://cooksync.app) account — a paid service that makes it easy to collect recipes from almost any recipe website.
![Import example](screenshots/recipe-example.png)
## Features
- Automatically sync your Cooksync recipes into Obsidian
- Includes recipe data, source, and cover image
- Option to automatically add tags to imported recipes
## Installation and Usage
1. Install the Cooksync plugin within Obsidian.
2. Enable the plugin.
3. Go to the plugin settings and connect your Cooksync account.
4. Customize the export settings, if desired.
5. Initiate the first sync.
6. Choose whether to keep auto-sync enabled or manually run the "Cooksync Sync" command to import recipes.
## Support
- Check our [Documentation](https://cooksync.app/docs).
- For additional assistance, please email us at info@cooksync.app.

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

425
main.ts Normal file
View file

@ -0,0 +1,425 @@
import {
App,
ButtonComponent,
Notice,
Plugin,
PluginSettingTab,
Setting,
normalizePath,
DataAdapter,
} from "obsidian";
const baseURL = process.env.COOKSYNC_SERVER_URL || "http://localhost:3000";
interface AuthResponse {
token: string;
}
interface ExportRequestResponse {
latest_id: number;
status: string;
}
interface CooksyncSettings {
token: string;
cooksyncDir: string;
isSyncing: boolean;
triggerOnLoad: boolean;
lastSyncFailed: boolean;
lastSyncTime?: number;
recipeIDs: number[];
}
const DEFAULT_SETTINGS: CooksyncSettings = {
token: "",
cooksyncDir: "Cooksync",
isSyncing: false,
triggerOnLoad: true,
lastSyncFailed: false,
lastSyncTime: undefined,
recipeIDs: [],
};
export default class Cooksync extends Plugin {
settings: CooksyncSettings;
fs: DataAdapter;
async onload() {
await this.loadSettings();
if (
this.settings.triggerOnLoad &&
(!this.settings.lastSyncTime ||
this.settings.lastSyncTime < Date.now() - 1000 * 60 * 60 * 2)
) {
this.startSync();
}
this.addCommand({
id: "cooksync-sync",
name: "Sync your data",
callback: () => {
this.startSync();
},
});
//TODO: add a command to resync one recipe
this.addSettingTab(new CooksyncSettingTab(this.app, this));
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(
window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000)
);
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
getAuthHeaders() {
return {
Authorization: `Bearer ${this.settings.token}`,
"Obsidian-Client": `${this.getObsidianClientID()}`,
};
}
getErrorMessageFromResponse(response?: Response) {
return `${response ? response.statusText : "Can't connect to server"}`;
}
handleSyncSuccess(
buttonContext?: ButtonComponent
// msg = "Synced",
// exportID: number | null = null
) {
this.settings.isSyncing = false;
this.settings.lastSyncFailed = false;
this.saveSettings();
if (buttonContext) {
buttonContext.buttonEl.setText("Run sync");
}
}
handleSyncError(msg: string, buttonContext?: ButtonComponent) {
this.settings.isSyncing = false;
this.settings.lastSyncFailed = true;
this.saveSettings();
if (buttonContext) {
buttonContext.buttonEl.setText("Run sync");
} else {
new Notice(msg);
}
}
async requestData(buttonContext?: ButtonComponent) {
const url = `${baseURL}/api/recipes/export/obsidian`;
let response, data: ExportRequestResponse;
try {
response = await fetch(url, {
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
exportTarget: "obsidian",
recipeIds: this.settings.recipeIDs,
}),
});
} catch (e) {
console.log("Cooksync: fetch failed in requestArchive: ", e);
}
if (response && response.ok) {
data = await response.json();
// If data, then we have new data to add
if (data) {
// new Notice("Syncing Cooksync data");
// const statusBarItemEl = this.addStatusBarItem();
// statusBarItemEl.setText("Cooksync: Syncing data");
// Parse response and save to Obsidian
this.downloadData(data, buttonContext);
}
// If no data, then we are up to date
if (!data) {
this.handleSyncSuccess(buttonContext);
new Notice("Cooksync data is already up to date");
return;
}
await this.saveSettings();
} else {
console.log(
"Cooksync plugin: bad response in requestData: ",
response
);
this.handleSyncError(
this.getErrorMessageFromResponse(response),
buttonContext
);
return;
}
}
async downloadData(
data: any,
buttonContext?: ButtonComponent
): Promise<void> {
console.log("Downloading data", data);
this.fs = this.app.vault.adapter;
const checkIfFileExists = async (fileName: string) => {
const exists = await this.fs.exists(fileName);
return exists;
};
for (const entry of data) {
const recipeTitle = entry.title.replaceAll("/", "");
const fileName = `${this.settings.cooksyncDir}/${recipeTitle}.md`;
const processedFileName = normalizePath(fileName);
console.log("processedFileName", processedFileName);
try {
// ensure the directory exists
const dirPath = processedFileName
.replace(/\/*$/, "")
.replace(/^(.+)\/[^\/]*?$/, "$1");
console.log("dirPath", dirPath);
const exists = await this.fs.exists(dirPath);
if (!exists) {
await this.fs.mkdir(dirPath);
}
// write the actual files
const content = entry.content;
let originalName = processedFileName;
const contentToSave = content;
const extension = originalName.split(".").pop();
const baseName = originalName.replace(`.${extension}`, "");
let count = 1;
while (await checkIfFileExists(originalName)) {
originalName = `${baseName} (${count}).${extension}`;
count++;
}
console.log("recipe to save:", originalName);
await this.fs.write(originalName, contentToSave);
this.settings.recipeIDs.push(entry.id);
this.settings.lastSyncTime = Date.now();
await this.saveSettings();
new Notice("Cooksync: sync completed");
} catch (e) {
console.log(`Cooksync: error writing ${processedFileName}:`, e);
new Notice(`Error writing file ${processedFileName}: ${e}`);
}
}
this.handleSyncSuccess(buttonContext);
}
startSync() {
if (this.settings.isSyncing) {
new Notice("Cooksync sync already in progress");
} else {
this.settings.isSyncing = true;
this.saveSettings();
this.requestData();
}
console.log("started sync");
}
getObsidianClientID() {
let obsidianClientId = window.localStorage.getItem(
"cooksync-ObsidianClientId"
);
if (obsidianClientId) {
return obsidianClientId;
} else {
obsidianClientId = Math.random().toString(36).substring(2, 15);
window.localStorage.setItem(
"cooksync-ObsidianClientId",
obsidianClientId
);
return obsidianClientId;
}
}
async getUserAuthToken(button: HTMLElement, attempt = 0) {
const uuid = this.getObsidianClientID();
console.log(uuid);
if (attempt === 0) {
window.open(`${baseURL}/export?uuid=${uuid}&service=obsidian`);
}
let response;
let data: AuthResponse;
try {
response = await fetch(`${baseURL}/api/clients/token?uuid=${uuid}`);
} catch (e) {
console.log(
"Cooksync plugin: fetch failed in getUserAuthToken: ",
e
);
new Notice("Authorization failed. Please try again");
}
if (response && response.ok) {
data = await response.json();
console.log("token", data);
} else {
console.log(
"Cooksync plugin: bad response in getUserAuthToken: ",
response
);
return;
}
if (data.token) {
this.settings.token = data.token;
} else {
if (attempt > 50) {
console.log(
"Cooksync plugin: reached attempt limit in getUserAuthToken"
);
return;
}
console.log(
`Cooksync plugin: didn't get token data, retrying (attempt ${
attempt + 1
})`
);
await new Promise((resolve) => setTimeout(resolve, 3000));
await this.getUserAuthToken(button, attempt + 1);
}
await this.saveSettings();
return true;
}
}
class CooksyncSettingTab extends PluginSettingTab {
plugin: Cooksync;
constructor(app: App, plugin: Cooksync) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h1", { text: "Cooksync" });
containerEl.createEl("p", { text: "Created by " }).createEl("a", {
text: "Cooksync",
href: "https://cooksync.app",
});
containerEl.createEl("h2", { text: "Settings" });
if (!this.plugin.settings.token) {
new Setting(containerEl)
.setName("Connect Obsidian to Cooksync")
.setDesc(
"Enable automatic syncing between Obsidian and Cooksync. Note: Requires Cooksync account"
)
.addButton((button) => {
button
.setButtonText("Connect")
.setCta()
.onClick(async (evt) => {
const success = await this.plugin.getUserAuthToken(
evt.target as HTMLElement
);
if (success) {
this.display();
}
});
});
}
if (this.plugin.settings.token) {
new Setting(containerEl)
.setName("Sync your Cooksync data with Obsidian")
.setDesc(
"On first sync, the Cooksync plugin will create a new folder containing all your recipes"
)
.addButton((button) => {
button
.setCta()
.setTooltip(
"Once the sync begins, you can close this plugin page"
)
.setButtonText("Initiate Sync")
.onClick(async () => {
if (this.plugin.settings.isSyncing) {
new Notice("Sync already in progress");
} else {
this.plugin.settings.isSyncing = true;
await this.plugin.saveData(
this.plugin.settings
);
button.setButtonText("Syncing...");
await this.plugin.requestData(button);
}
});
});
new Setting(containerEl)
.setName("Customize import options")
.setDesc("Customize recipe import, such as tags")
.addButton((button) => {
button.setButtonText("Customize").onClick(() => {
window.open(`${baseURL}/export/obsidian`);
});
});
new Setting(containerEl)
.setName("Customize base folder")
.setDesc(
"By default, the plugin will save all your recipes into a folder named Cooksync"
)
.addText((text) =>
text
.setPlaceholder("Defaults to: Cooksync")
.setValue(this.plugin.settings.cooksyncDir)
.onChange(async (value) => {
this.plugin.settings.cooksyncDir = normalizePath(
value || "Cooksync"
);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Sync automatically when Obsidian opens")
.setDesc(
"If enabled, Obsidian will automatically resync with Cooksync each time the app is opened"
)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.triggerOnLoad);
toggle.onChange((val) => {
this.plugin.settings.triggerOnLoad = val;
this.plugin.saveSettings();
});
});
}
const help = containerEl.createEl("p");
help.innerHTML =
"Issues? Please email us at <a href='mailto:info@cooksync.app'>info@cooksync.app</a>.";
}
}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "cooksync",
"name": "Cooksync",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Automatically imports Cooksync data to Obsidian.",
"author": "Cooksync",
"authorUrl": "https://cooksync.app",
"fundingUrl": "https://cooksync.app/pricing",
"isDesktopOnly": false
}

2229
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "cooksync-obsidian",
"version": "1.0.0",
"description": "Cooksync Obsidian sync (https://cooksync.app)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

8
styles.css Normal file
View file

@ -0,0 +1,8 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}