iobroker.time-tracker 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -13
- package/build/lib/adapter/commands.js +12 -0
- package/build/lib/adapter/commands.js.map +2 -2
- package/build/lib/adapter/stateNames.js +84 -0
- package/build/lib/adapter/stateNames.js.map +2 -2
- package/build/lib/adapter/states.js +44 -1
- package/build/lib/adapter/states.js.map +2 -2
- package/build/lib/services/calendar.js +125 -0
- package/build/lib/services/calendar.js.map +7 -0
- package/build/lib/web/api.js +26 -69
- package/build/lib/web/api.js.map +2 -2
- package/build/lib/web/problem.js.map +2 -2
- package/build/main.js +44 -1
- package/build/main.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +0 -1
- package/src-pwa/src/i18n/en.json +0 -1
- package/src-pwa/src/i18n/es.json +0 -1
- package/src-pwa/src/i18n/fr.json +0 -1
- package/src-pwa/src/i18n/it.json +0 -1
- package/src-pwa/src/i18n/nl.json +0 -1
- package/src-pwa/src/i18n/pl.json +0 -1
- package/src-pwa/src/i18n/pt.json +0 -1
- package/src-pwa/src/i18n/ru.json +0 -1
- package/src-pwa/src/i18n/uk.json +0 -1
- package/src-pwa/src/i18n/zh-cn.json +0 -1
- package/www/assets/{index-C0z1CkCi.js → index-DB_lTeVU.js} +2 -2
- package/www/assets/{index-C0z1CkCi.js.map → index-DB_lTeVU.js.map} +1 -1
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/adapter/states.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * States published by the adapter.\n *\n * The adapter exposes a small, stable state tree so that other adapters (and the user's dashboards) can show\n * and trigger time tracking without speaking to the REST API:\n *\n * ```\n * users.<userId>.displayName / present / hasOpenEntry / lastPunch / todayWorkedMinutes / todayBalanceMinutes\n * users.<userId>.openConflicts\n * commands.punchUserId / punch / quickPunch / closeMonth / recalc\n * ```\n *\n * All objects are created with `setObjectNotExists`, so existing instances keep their settings. The logic works\n * on a tiny port interface (`StatePort`) \u2014 the adapter instance in production, a recorder in the tests.\n */\n\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport type { AggregationService } from \"../services/aggregation\";\nimport type { SyncService } from \"../services/sync\";\nimport { STATE_NAMES, type StateName } from \"./stateNames\";\nimport { localDate as resolveLocalDate } from \"../util/time\";\n\n/** Subset of the adapter API the state layer needs. */\nexport interface StatePort {\n\t/**\n\t * Creates an object when it does not exist yet.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - object definition\n\t */\n\tsetObjectNotExists(id: string, object: ioBroker.SettableObject): Promise<unknown> | void;\n\t/**\n\t * Merges fields into an object that already exists.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - fields to merge\n\t */\n\textendObject(id: string, object: ioBroker.PartialObject): Promise<unknown> | void;\n\t/**\n\t * Writes a state value.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param value - value to write\n\t * @param ack - true for confirmed values\n\t */\n\tsetState(id: string, value: ioBroker.StateValue, ack?: boolean): Promise<unknown> | void;\n}\n\n/** Figures of one employee shown in the state tree. */\nexport interface UserSnapshot {\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** True when the last punch of today has no counterpart */\n\thasOpenEntry: boolean;\n\t/** Instant of the last punch of the day, `null` when there is none */\n\tlastPunchUtc: number | null;\n\t/** Net working time of today in minutes */\n\tworkedMinutes: number;\n\t/** Balance of today in minutes */\n\tbalanceMinutes: number;\n\t/** Punches waiting for a decision */\n\topenConflicts: number;\n\t/** Net working time of the current month in minutes */\n\tmonthWorkedMinutes: number;\n\t/** Balance of the current month in minutes */\n\tmonthBalanceMinutes: number;\n\t/** Balance of the current year in minutes */\n\tyearBalanceMinutes: number;\n}\n\n/** Figures of the whole company, published next to the employees. */\nexport interface CompanySnapshot {\n\t/** Employees that are clocked in right now */\n\tpresentCount: number;\n\t/** Names of those employees, separated by a comma (empty when nobody is present) */\n\tpresent: string;\n\t/** Punches of all employees waiting for a decision */\n\topenConflicts: number;\n\t/** Instant of the newest punch of today, `null` when nobody punched */\n\tlastPunchUtc: number | null;\n}\n\n/** Newest event of the instance, mirrored into the state tree. */\nexport interface EventSnapshot {\n\t/** Kind of the event, e.g. `punch` */\n\ttype: string;\n\t/** Instant the event belongs to */\n\tatUtc: number;\n\t/** Name of the employee the event belongs to, empty for instance wide events */\n\tuserName: string;\n\t/** Direction of a punch (`in`/`out`), empty for other events */\n\tdirection: string;\n\t/** Where the change came from, e.g. `web` or `terminal`, empty when unknown */\n\tsource: string;\n}\n\n/** Names of the writable command states. */\nexport const COMMAND_IDS = {\n\t/** Employee the commands apply to */\n\tpunchUserId: \"commands.punchUserId\",\n\t/** Punch in or out for `punchUserId` */\n\tpunch: \"commands.punch\",\n\t/** Punch in or out with the configured quick rounding */\n\tquickPunch: \"commands.quickPunch\",\n\t/** Close a month, value `YYYY-MM` */\n\tcloseMonth: \"commands.closeMonth\",\n\t/** Recalculate a period, value `YYYY-MM` or `YYYY` */\n\trecalc: \"commands.recalc\",\n\t/** Write a backup of the database */\n\tbackup: \"commands.backup\",\n} as const;\n\n/**\n * Builds a state object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @param type - state type\n * @param role - state role\n * @param options - additional flags\n * @param options.read - value can be read (default true)\n * @param options.write - value can be written (default false)\n * @param options.unit - unit of the value\n * @returns object definition\n */\nfunction stateObject(\n\tname: StateName,\n\ttype: ioBroker.CommonType,\n\trole: string,\n\toptions: { read?: boolean; write?: boolean; unit?: string } = {},\n): ioBroker.SettableObject {\n\treturn {\n\t\ttype: \"state\",\n\t\tcommon: {\n\t\t\tname,\n\t\t\ttype,\n\t\t\trole,\n\t\t\tread: options.read !== false,\n\t\t\twrite: options.write === true,\n\t\t\t...(options.unit ? { unit: options.unit } : {}),\n\t\t},\n\t\tnative: {},\n\t};\n}\n\n/**\n * Builds a channel object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @returns object definition\n */\nfunction channelObject(name: StateName): ioBroker.SettableObject {\n\treturn { type: \"channel\", common: { name }, native: {} };\n}\n\n/**\n * Creates an object when it is missing and keeps the definition of an existing one current.\n *\n * `setObjectNotExists` alone would leave existing installations on the definition they were created with \u2014 a later\n * correction (for example the role of `commands.punchUserId`, checker `E1011`) would never arrive. So the fields the\n * adapter owns are merged on every start; `common.custom` is left out, that is where the user configures `history`\n * and similar integrations.\n *\n * @param port - state port\n * @param id - full state id (including the instance prefix)\n * @param object - object definition\n */\nasync function ensureObject(port: StatePort, id: string, object: ioBroker.SettableObject): Promise<void> {\n\tawait port.setObjectNotExists(id, object);\n\tconst common: Record<string, unknown> = { ...(object.common ?? {}) };\n\tdelete common.custom;\n\tawait port.extendObject(id, { common });\n}\n\n/**\n * Reads the figures of an employee for the state tree.\n *\n * @param args - data sources and employee\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service (open conflicts)\n * @param args.userId - database id of the employee\n * @param args.now - instant of the snapshot\n * @returns snapshot of the figures\n */\nexport function readUserSnapshot(args: {\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tuserId: number;\n\tnow?: number;\n}): UserSnapshot | null {\n\tconst user = args.users.findById(args.userId);\n\tif (!user) {\n\t\treturn null;\n\t}\n\n\tconst timestamp = args.now ?? Math.floor(Date.now() / 1000);\n\tconst today = resolveLocalDate(timestamp, user.timezone);\n\tconst day = args.aggregation.recalculateDay(user.id, today, { now: timestamp });\n\t// the month is recalculated like the month view does it; the year is read, so the publishing stays cheap\n\tconst year = Number(today.slice(0, 4));\n\tconst month = Number(today.slice(5, 7));\n\tconst monthAggregate = args.aggregation.recalculateMonth(user.id, year, month, { now: timestamp });\n\tconst yearAggregate = args.aggregation.year(user.id, year);\n\n\treturn {\n\t\tuserId: user.id,\n\t\tdisplayName: user.displayName,\n\t\thasOpenEntry: day.hasOpenEntry,\n\t\tlastPunchUtc: Math.max(day.firstInUtc ?? 0, day.lastOutUtc ?? 0) || null,\n\t\tworkedMinutes: day.workedMin,\n\t\tbalanceMinutes: day.balanceMin,\n\t\topenConflicts: args.sync.conflicts(user.id).length,\n\t\tmonthWorkedMinutes: monthAggregate.workedMin,\n\t\tmonthBalanceMinutes: monthAggregate.balanceMin,\n\t\tyearBalanceMinutes: yearAggregate ? yearAggregate.workedMin - yearAggregate.targetMin : 0,\n\t};\n}\n\n/**\n * Publishes the figures of one employee.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishUserSnapshot(port: StatePort, snapshot: UserSnapshot): Promise<void> {\n\tconst id = `users.${snapshot.userId}`;\n\tawait port.setState(`${id}.displayName`, snapshot.displayName, true);\n\tawait port.setState(`${id}.present`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.hasOpenEntry`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.lastPunch`, snapshot.lastPunchUtc ?? 0, true);\n\tawait port.setState(`${id}.todayWorkedMinutes`, snapshot.workedMinutes, true);\n\tawait port.setState(`${id}.todayBalanceMinutes`, snapshot.balanceMinutes, true);\n\tawait port.setState(`${id}.monthWorkedMinutes`, snapshot.monthWorkedMinutes, true);\n\tawait port.setState(`${id}.monthBalanceMinutes`, snapshot.monthBalanceMinutes, true);\n\tawait port.setState(`${id}.yearBalanceMinutes`, snapshot.yearBalanceMinutes, true);\n\tawait port.setState(`${id}.openConflicts`, snapshot.openConflicts, true);\n}\n\n/**\n * Creates the channels of all employees and publishes their current figures.\n *\n * @param args - state port and data sources\n * @param args.port - state port\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service\n * @param args.now - instant of the snapshot\n * @returns the published snapshots\n */\nexport async function publishAllUserStates(args: {\n\tport: StatePort;\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tnow?: number;\n}): Promise<UserSnapshot[]> {\n\tconst snapshots: UserSnapshot[] = [];\n\tfor (const user of args.users.list({ includeInactive: true })) {\n\t\tawait createUserChannel(args.port, user.id);\n\t\tconst snapshot = readUserSnapshot({\n\t\t\taggregation: args.aggregation,\n\t\t\tusers: args.users,\n\t\t\tsync: args.sync,\n\t\t\tuserId: user.id,\n\t\t\tnow: args.now,\n\t\t});\n\t\tif (!snapshot) {\n\t\t\tcontinue;\n\t\t}\n\t\tawait publishUserSnapshot(args.port, snapshot);\n\t\tsnapshots.push(snapshot);\n\t}\n\treturn snapshots;\n}\n\n/**\n * Creates the channel of one employee, including the shared parent channel `users`.\n *\n * The parent channel matters: the ioBroker object structure check (`E3009`) requires every state to have its\n * parents, so `users.<userId>.*` below a missing `users` produced 126 findings in the repository check\n * (PR #6697). `setObjectNotExists` keeps the call idempotent.\n *\n * @param port - state port\n * @param userId - database id of the employee\n * @returns the channel id\n */\nexport async function createUserChannel(port: StatePort, userId: number): Promise<string> {\n\tconst id = `users.${userId}`;\n\tawait ensureObject(port, \"users\", channelObject(STATE_NAMES.usersChannel));\n\tawait ensureObject(port, id, channelObject(STATE_NAMES.employeeChannel));\n\tawait ensureObject(port, `${id}.displayName`, stateObject(STATE_NAMES.displayName, \"string\", \"info.name\"));\n\t// the writable twin of `hasOpenEntry`: a script, a fingerprint reader or a dashboard writes it to say that\n\t// somebody arrived (`true`) or left (`false`) \u2014 see `presence.ts` for what the adapter does with it\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.present`,\n\t\tstateObject(STATE_NAMES.userPresent, \"boolean\", \"switch\", { write: true }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.hasOpenEntry`,\n\t\tstateObject(STATE_NAMES.hasOpenEntry, \"boolean\", \"indicator.working\"),\n\t);\n\tawait ensureObject(port, `${id}.lastPunch`, stateObject(STATE_NAMES.lastPunch, \"number\", \"value.time\"));\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.todayWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.todayBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.monthWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.monthBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.yearBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.yearBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(port, `${id}.openConflicts`, stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\treturn id;\n}\n\n/**\n * Creates the writable command states.\n *\n * @param port - state port\n */\nexport async function createCommandStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"commands\", channelObject(STATE_NAMES.commandsChannel));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punchUserId,\n\t\tstateObject(STATE_NAMES.punchUserId, \"number\", \"level\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punch,\n\t\tstateObject(STATE_NAMES.punch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.quickPunch,\n\t\tstateObject(STATE_NAMES.quickPunch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.closeMonth,\n\t\tstateObject(STATE_NAMES.closeMonth, \"string\", \"text\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(port, COMMAND_IDS.recalc, stateObject(STATE_NAMES.recalc, \"string\", \"text\", { write: true }));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.backup,\n\t\tstateObject(STATE_NAMES.backupCommand, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n}\n\n/**\n * Creates the informational states of the instance.\n *\n * @param port - state port\n */\nexport async function createInfoStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"info\", channelObject(STATE_NAMES.infoChannel));\n\tawait ensureObject(port, \"info.version\", stateObject(STATE_NAMES.version, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.schemaVersion\", stateObject(STATE_NAMES.schemaVersion, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\t\"info.dbSizeBytes\",\n\t\tstateObject(STATE_NAMES.dbSizeBytes, \"number\", \"value\", { unit: \"bytes\" }),\n\t);\n\tawait ensureObject(port, \"info.lastError\", stateObject(STATE_NAMES.lastError, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.lastBackup\", stateObject(STATE_NAMES.lastBackup, \"number\", \"value.time\"));\n}\n\n/**\n * Creates the states of the company.\n *\n * @param port - state port\n */\nexport async function createCompanyStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"company\", channelObject(STATE_NAMES.companyChannel));\n\tawait ensureObject(port, \"company.presentCount\", stateObject(STATE_NAMES.companyPresentCount, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.present\", stateObject(STATE_NAMES.companyPresent, \"string\", \"text\"));\n\tawait ensureObject(port, \"company.openConflicts\", stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.lastPunch\", stateObject(STATE_NAMES.companyLastPunch, \"number\", \"value.time\"));\n}\n\n/**\n * Reads the company figures out of the figures of the employees.\n *\n * @param snapshots - figures of all employees\n * @returns the company figures\n */\nexport function readCompanySnapshot(snapshots: UserSnapshot[]): CompanySnapshot {\n\tconst present = snapshots.filter(snapshot => snapshot.hasOpenEntry);\n\treturn {\n\t\tpresentCount: present.length,\n\t\tpresent: present.map(snapshot => snapshot.displayName).join(\", \"),\n\t\topenConflicts: snapshots.reduce((sum, snapshot) => sum + snapshot.openConflicts, 0),\n\t\tlastPunchUtc: snapshots.reduce<number | null>(\n\t\t\t(newest, snapshot) =>\n\t\t\t\tsnapshot.lastPunchUtc !== null && (newest === null || snapshot.lastPunchUtc > newest)\n\t\t\t\t\t? snapshot.lastPunchUtc\n\t\t\t\t\t: newest,\n\t\t\tnull,\n\t\t),\n\t};\n}\n\n/**\n * Publishes the company figures.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishCompanySnapshot(port: StatePort, snapshot: CompanySnapshot): Promise<void> {\n\tawait port.setState(\"company.presentCount\", snapshot.presentCount, true);\n\tawait port.setState(\"company.present\", snapshot.present, true);\n\tawait port.setState(\"company.openConflicts\", snapshot.openConflicts, true);\n\tawait port.setState(\"company.lastPunch\", snapshot.lastPunchUtc ?? 0, true);\n}\n\n/**\n * Creates the event states.\n *\n * @param port - state port\n */\nexport async function createEventStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"events\", channelObject(STATE_NAMES.eventsChannel));\n\tawait ensureObject(port, \"events.lastAt\", stateObject(STATE_NAMES.lastAt, \"number\", \"value.time\"));\n\tawait ensureObject(port, \"events.lastType\", stateObject(STATE_NAMES.lastType, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastUser\", stateObject(STATE_NAMES.lastUser, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastDirection\", stateObject(STATE_NAMES.lastDirection, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastSource\", stateObject(STATE_NAMES.lastSource, \"string\", \"text\"));\n}\n\n/**\n * Publishes the newest event.\n *\n * @param port - state port\n * @param snapshot - event to publish\n */\nexport async function publishEventSnapshot(port: StatePort, snapshot: EventSnapshot): Promise<void> {\n\tawait port.setState(\"events.lastAt\", snapshot.atUtc, true);\n\tawait port.setState(\"events.lastType\", snapshot.type, true);\n\tawait port.setState(\"events.lastUser\", snapshot.userName, true);\n\tawait port.setState(\"events.lastDirection\", snapshot.direction, true);\n\tawait port.setState(\"events.lastSource\", snapshot.source, true);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,wBAA4C;AAC5C,kBAA8C;AA+EvC,MAAM,cAAc;AAAA;AAAA,EAE1B,aAAa;AAAA;AAAA,EAEb,OAAO;AAAA;AAAA,EAEP,YAAY;AAAA;AAAA,EAEZ,YAAY;AAAA;AAAA,EAEZ,QAAQ;AAAA;AAAA,EAER,QAAQ;
|
|
4
|
+
"sourcesContent": ["/**\n * States published by the adapter.\n *\n * The adapter exposes a small, stable state tree so that other adapters (and the user's dashboards) can show\n * and trigger time tracking without speaking to the REST API:\n *\n * ```\n * users.<userId>.displayName / present / hasOpenEntry / lastPunch / todayWorkedMinutes / todayBalanceMinutes\n * users.<userId>.openConflicts\n * commands.punchUserId / punch / quickPunch / closeMonth / recalc\n * ```\n *\n * All objects are created with `setObjectNotExists`, so existing instances keep their settings. The logic works\n * on a tiny port interface (`StatePort`) \u2014 the adapter instance in production, a recorder in the tests.\n */\n\nimport type { UsersRepository } from \"../db/repositories/users\";\nimport type { AggregationService } from \"../services/aggregation\";\nimport type { SyncService } from \"../services/sync\";\nimport { STATE_NAMES, type StateName } from \"./stateNames\";\nimport { localDate as resolveLocalDate } from \"../util/time\";\n\n/** Subset of the adapter API the state layer needs. */\nexport interface StatePort {\n\t/**\n\t * Creates an object when it does not exist yet.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - object definition\n\t */\n\tsetObjectNotExists(id: string, object: ioBroker.SettableObject): Promise<unknown> | void;\n\t/**\n\t * Merges fields into an object that already exists.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param object - fields to merge\n\t */\n\textendObject(id: string, object: ioBroker.PartialObject): Promise<unknown> | void;\n\t/**\n\t * Writes a state value.\n\t *\n\t * @param id - full state id (including the instance prefix)\n\t * @param value - value to write\n\t * @param ack - true for confirmed values\n\t */\n\tsetState(id: string, value: ioBroker.StateValue, ack?: boolean): Promise<unknown> | void;\n}\n\n/** Figures of one employee shown in the state tree. */\nexport interface UserSnapshot {\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** True when the last punch of today has no counterpart */\n\thasOpenEntry: boolean;\n\t/** Instant of the last punch of the day, `null` when there is none */\n\tlastPunchUtc: number | null;\n\t/** Net working time of today in minutes */\n\tworkedMinutes: number;\n\t/** Balance of today in minutes */\n\tbalanceMinutes: number;\n\t/** Punches waiting for a decision */\n\topenConflicts: number;\n\t/** Net working time of the current month in minutes */\n\tmonthWorkedMinutes: number;\n\t/** Balance of the current month in minutes */\n\tmonthBalanceMinutes: number;\n\t/** Balance of the current year in minutes */\n\tyearBalanceMinutes: number;\n}\n\n/** Figures of the whole company, published next to the employees. */\nexport interface CompanySnapshot {\n\t/** Employees that are clocked in right now */\n\tpresentCount: number;\n\t/** Names of those employees, separated by a comma (empty when nobody is present) */\n\tpresent: string;\n\t/** Punches of all employees waiting for a decision */\n\topenConflicts: number;\n\t/** Instant of the newest punch of today, `null` when nobody punched */\n\tlastPunchUtc: number | null;\n}\n\n/** Newest event of the instance, mirrored into the state tree. */\nexport interface EventSnapshot {\n\t/** Kind of the event, e.g. `punch` */\n\ttype: string;\n\t/** Instant the event belongs to */\n\tatUtc: number;\n\t/** Name of the employee the event belongs to, empty for instance wide events */\n\tuserName: string;\n\t/** Direction of a punch (`in`/`out`), empty for other events */\n\tdirection: string;\n\t/** Where the change came from, e.g. `web` or `terminal`, empty when unknown */\n\tsource: string;\n}\n\n/** Names of the writable command states. */\nexport const COMMAND_IDS = {\n\t/** Employee the commands apply to */\n\tpunchUserId: \"commands.punchUserId\",\n\t/** Punch in or out for `punchUserId` */\n\tpunch: \"commands.punch\",\n\t/** Punch in or out with the configured quick rounding */\n\tquickPunch: \"commands.quickPunch\",\n\t/** Close a month, value `YYYY-MM` */\n\tcloseMonth: \"commands.closeMonth\",\n\t/** Recalculate a period, value `YYYY-MM` or `YYYY` */\n\trecalc: \"commands.recalc\",\n\t/** Write a backup of the database */\n\tbackup: \"commands.backup\",\n\t/** Create or renew the calendar link of the company */\n\trotateCalendarToken: \"commands.rotateCalendarToken\",\n} as const;\n\n/**\n * Builds a state object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @param type - state type\n * @param role - state role\n * @param options - additional flags\n * @param options.read - value can be read (default true)\n * @param options.write - value can be written (default false)\n * @param options.unit - unit of the value\n * @returns object definition\n */\nfunction stateObject(\n\tname: StateName,\n\ttype: ioBroker.CommonType,\n\trole: string,\n\toptions: { read?: boolean; write?: boolean; unit?: string } = {},\n): ioBroker.SettableObject {\n\treturn {\n\t\ttype: \"state\",\n\t\tcommon: {\n\t\t\tname,\n\t\t\ttype,\n\t\t\trole,\n\t\t\tread: options.read !== false,\n\t\t\twrite: options.write === true,\n\t\t\t...(options.unit ? { unit: options.unit } : {}),\n\t\t},\n\t\tnative: {},\n\t};\n}\n\n/**\n * Builds a channel object definition.\n *\n * @param name - name in English and German\n * @param name.en - English name\n * @param name.de - German name\n * @returns object definition\n */\nfunction channelObject(name: StateName): ioBroker.SettableObject {\n\treturn { type: \"channel\", common: { name }, native: {} };\n}\n\n/**\n * Creates an object when it is missing and keeps the definition of an existing one current.\n *\n * `setObjectNotExists` alone would leave existing installations on the definition they were created with \u2014 a later\n * correction (for example the role of `commands.punchUserId`, checker `E1011`) would never arrive. So the fields the\n * adapter owns are merged on every start; `common.custom` is left out, that is where the user configures `history`\n * and similar integrations.\n *\n * @param port - state port\n * @param id - full state id (including the instance prefix)\n * @param object - object definition\n */\nasync function ensureObject(port: StatePort, id: string, object: ioBroker.SettableObject): Promise<void> {\n\tawait port.setObjectNotExists(id, object);\n\tconst common: Record<string, unknown> = { ...(object.common ?? {}) };\n\tdelete common.custom;\n\tawait port.extendObject(id, { common });\n}\n\n/**\n * Reads the figures of an employee for the state tree.\n *\n * @param args - data sources and employee\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service (open conflicts)\n * @param args.userId - database id of the employee\n * @param args.now - instant of the snapshot\n * @returns snapshot of the figures\n */\nexport function readUserSnapshot(args: {\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tuserId: number;\n\tnow?: number;\n}): UserSnapshot | null {\n\tconst user = args.users.findById(args.userId);\n\tif (!user) {\n\t\treturn null;\n\t}\n\n\tconst timestamp = args.now ?? Math.floor(Date.now() / 1000);\n\tconst today = resolveLocalDate(timestamp, user.timezone);\n\tconst day = args.aggregation.recalculateDay(user.id, today, { now: timestamp });\n\t// the month is recalculated like the month view does it; the year is read, so the publishing stays cheap\n\tconst year = Number(today.slice(0, 4));\n\tconst month = Number(today.slice(5, 7));\n\tconst monthAggregate = args.aggregation.recalculateMonth(user.id, year, month, { now: timestamp });\n\tconst yearAggregate = args.aggregation.year(user.id, year);\n\n\treturn {\n\t\tuserId: user.id,\n\t\tdisplayName: user.displayName,\n\t\thasOpenEntry: day.hasOpenEntry,\n\t\tlastPunchUtc: Math.max(day.firstInUtc ?? 0, day.lastOutUtc ?? 0) || null,\n\t\tworkedMinutes: day.workedMin,\n\t\tbalanceMinutes: day.balanceMin,\n\t\topenConflicts: args.sync.conflicts(user.id).length,\n\t\tmonthWorkedMinutes: monthAggregate.workedMin,\n\t\tmonthBalanceMinutes: monthAggregate.balanceMin,\n\t\tyearBalanceMinutes: yearAggregate ? yearAggregate.workedMin - yearAggregate.targetMin : 0,\n\t};\n}\n\n/**\n * Publishes the figures of one employee.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishUserSnapshot(port: StatePort, snapshot: UserSnapshot): Promise<void> {\n\tconst id = `users.${snapshot.userId}`;\n\tawait port.setState(`${id}.displayName`, snapshot.displayName, true);\n\tawait port.setState(`${id}.present`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.hasOpenEntry`, snapshot.hasOpenEntry, true);\n\tawait port.setState(`${id}.lastPunch`, snapshot.lastPunchUtc ?? 0, true);\n\tawait port.setState(`${id}.todayWorkedMinutes`, snapshot.workedMinutes, true);\n\tawait port.setState(`${id}.todayBalanceMinutes`, snapshot.balanceMinutes, true);\n\tawait port.setState(`${id}.monthWorkedMinutes`, snapshot.monthWorkedMinutes, true);\n\tawait port.setState(`${id}.monthBalanceMinutes`, snapshot.monthBalanceMinutes, true);\n\tawait port.setState(`${id}.yearBalanceMinutes`, snapshot.yearBalanceMinutes, true);\n\tawait port.setState(`${id}.openConflicts`, snapshot.openConflicts, true);\n}\n\n/**\n * Creates the channels of all employees and publishes their current figures.\n *\n * @param args - state port and data sources\n * @param args.port - state port\n * @param args.aggregation - aggregation service\n * @param args.users - user storage\n * @param args.sync - synchronisation service\n * @param args.now - instant of the snapshot\n * @returns the published snapshots\n */\nexport async function publishAllUserStates(args: {\n\tport: StatePort;\n\taggregation: AggregationService;\n\tusers: UsersRepository;\n\tsync: SyncService;\n\tnow?: number;\n}): Promise<UserSnapshot[]> {\n\tconst snapshots: UserSnapshot[] = [];\n\tfor (const user of args.users.list({ includeInactive: true })) {\n\t\tawait createUserChannel(args.port, user.id);\n\t\tconst snapshot = readUserSnapshot({\n\t\t\taggregation: args.aggregation,\n\t\t\tusers: args.users,\n\t\t\tsync: args.sync,\n\t\t\tuserId: user.id,\n\t\t\tnow: args.now,\n\t\t});\n\t\tif (!snapshot) {\n\t\t\tcontinue;\n\t\t}\n\t\tawait publishUserSnapshot(args.port, snapshot);\n\t\tsnapshots.push(snapshot);\n\t}\n\treturn snapshots;\n}\n\n/**\n * Creates the channel of one employee, including the shared parent channel `users`.\n *\n * The parent channel matters: the ioBroker object structure check (`E3009`) requires every state to have its\n * parents, so `users.<userId>.*` below a missing `users` produced 126 findings in the repository check\n * (PR #6697). `setObjectNotExists` keeps the call idempotent.\n *\n * @param port - state port\n * @param userId - database id of the employee\n * @returns the channel id\n */\nexport async function createUserChannel(port: StatePort, userId: number): Promise<string> {\n\tconst id = `users.${userId}`;\n\tawait ensureObject(port, \"users\", channelObject(STATE_NAMES.usersChannel));\n\tawait ensureObject(port, id, channelObject(STATE_NAMES.employeeChannel));\n\tawait ensureObject(port, `${id}.displayName`, stateObject(STATE_NAMES.displayName, \"string\", \"info.name\"));\n\t// the writable twin of `hasOpenEntry`: a script, a fingerprint reader or a dashboard writes it to say that\n\t// somebody arrived (`true`) or left (`false`) \u2014 see `presence.ts` for what the adapter does with it\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.present`,\n\t\tstateObject(STATE_NAMES.userPresent, \"boolean\", \"switch\", { write: true }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.hasOpenEntry`,\n\t\tstateObject(STATE_NAMES.hasOpenEntry, \"boolean\", \"indicator.working\"),\n\t);\n\tawait ensureObject(port, `${id}.lastPunch`, stateObject(STATE_NAMES.lastPunch, \"number\", \"value.time\"));\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.todayWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.todayBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.todayBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthWorkedMinutes`,\n\t\tstateObject(STATE_NAMES.monthWorkedMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.monthBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.monthBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\t`${id}.yearBalanceMinutes`,\n\t\tstateObject(STATE_NAMES.yearBalanceMinutes, \"number\", \"value\", { unit: \"min\" }),\n\t);\n\tawait ensureObject(port, `${id}.openConflicts`, stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\treturn id;\n}\n\n/**\n * Creates the writable command states.\n *\n * @param port - state port\n */\nexport async function createCommandStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"commands\", channelObject(STATE_NAMES.commandsChannel));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punchUserId,\n\t\tstateObject(STATE_NAMES.punchUserId, \"number\", \"level\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.punch,\n\t\tstateObject(STATE_NAMES.punch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.quickPunch,\n\t\tstateObject(STATE_NAMES.quickPunch, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.closeMonth,\n\t\tstateObject(STATE_NAMES.closeMonth, \"string\", \"text\", {\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(port, COMMAND_IDS.recalc, stateObject(STATE_NAMES.recalc, \"string\", \"text\", { write: true }));\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.backup,\n\t\tstateObject(STATE_NAMES.backupCommand, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n\tawait ensureObject(\n\t\tport,\n\t\tCOMMAND_IDS.rotateCalendarToken,\n\t\tstateObject(STATE_NAMES.rotateCalendarToken, \"boolean\", \"button\", {\n\t\t\tread: false,\n\t\t\twrite: true,\n\t\t}),\n\t);\n}\n\n/**\n * Creates the informational states of the instance.\n *\n * @param port - state port\n */\nexport async function createInfoStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"info\", channelObject(STATE_NAMES.infoChannel));\n\tawait ensureObject(port, \"info.version\", stateObject(STATE_NAMES.version, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.schemaVersion\", stateObject(STATE_NAMES.schemaVersion, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\t\"info.dbSizeBytes\",\n\t\tstateObject(STATE_NAMES.dbSizeBytes, \"number\", \"value\", { unit: \"bytes\" }),\n\t);\n\tawait ensureObject(port, \"info.lastError\", stateObject(STATE_NAMES.lastError, \"string\", \"text\"));\n\tawait ensureObject(port, \"info.lastBackup\", stateObject(STATE_NAMES.lastBackup, \"number\", \"value.time\"));\n}\n\n/**\n * Creates the states of the company.\n *\n * @param port - state port\n */\nexport async function createCompanyStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"company\", channelObject(STATE_NAMES.companyChannel));\n\tawait ensureObject(port, \"company.presentCount\", stateObject(STATE_NAMES.companyPresentCount, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.present\", stateObject(STATE_NAMES.companyPresent, \"string\", \"text\"));\n\tawait ensureObject(port, \"company.openConflicts\", stateObject(STATE_NAMES.openConflicts, \"number\", \"value\"));\n\tawait ensureObject(port, \"company.lastPunch\", stateObject(STATE_NAMES.companyLastPunch, \"number\", \"value.time\"));\n}\n\n/**\n * Reads the company figures out of the figures of the employees.\n *\n * @param snapshots - figures of all employees\n * @returns the company figures\n */\nexport function readCompanySnapshot(snapshots: UserSnapshot[]): CompanySnapshot {\n\tconst present = snapshots.filter(snapshot => snapshot.hasOpenEntry);\n\treturn {\n\t\tpresentCount: present.length,\n\t\tpresent: present.map(snapshot => snapshot.displayName).join(\", \"),\n\t\topenConflicts: snapshots.reduce((sum, snapshot) => sum + snapshot.openConflicts, 0),\n\t\tlastPunchUtc: snapshots.reduce<number | null>(\n\t\t\t(newest, snapshot) =>\n\t\t\t\tsnapshot.lastPunchUtc !== null && (newest === null || snapshot.lastPunchUtc > newest)\n\t\t\t\t\t? snapshot.lastPunchUtc\n\t\t\t\t\t: newest,\n\t\t\tnull,\n\t\t),\n\t};\n}\n\n/**\n * Publishes the company figures.\n *\n * @param port - state port\n * @param snapshot - figures to publish\n */\nexport async function publishCompanySnapshot(port: StatePort, snapshot: CompanySnapshot): Promise<void> {\n\tawait port.setState(\"company.presentCount\", snapshot.presentCount, true);\n\tawait port.setState(\"company.present\", snapshot.present, true);\n\tawait port.setState(\"company.openConflicts\", snapshot.openConflicts, true);\n\tawait port.setState(\"company.lastPunch\", snapshot.lastPunchUtc ?? 0, true);\n}\n\n/**\n * Creates the event states.\n *\n * @param port - state port\n */\nexport async function createEventStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"events\", channelObject(STATE_NAMES.eventsChannel));\n\tawait ensureObject(port, \"events.lastAt\", stateObject(STATE_NAMES.lastAt, \"number\", \"value.time\"));\n\tawait ensureObject(port, \"events.lastType\", stateObject(STATE_NAMES.lastType, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastUser\", stateObject(STATE_NAMES.lastUser, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastDirection\", stateObject(STATE_NAMES.lastDirection, \"string\", \"text\"));\n\tawait ensureObject(port, \"events.lastSource\", stateObject(STATE_NAMES.lastSource, \"string\", \"text\"));\n}\n\n/**\n * Publishes the newest event.\n *\n * @param port - state port\n * @param snapshot - event to publish\n */\nexport async function publishEventSnapshot(port: StatePort, snapshot: EventSnapshot): Promise<void> {\n\tawait port.setState(\"events.lastAt\", snapshot.atUtc, true);\n\tawait port.setState(\"events.lastType\", snapshot.type, true);\n\tawait port.setState(\"events.lastUser\", snapshot.userName, true);\n\tawait port.setState(\"events.lastDirection\", snapshot.direction, true);\n\tawait port.setState(\"events.lastSource\", snapshot.source, true);\n}\n\n/** Ids of the calendar states; the adapter publishes them from `main.ts`. */\nexport const CALENDAR_IDS = {\n\t/** Subscription link of the company calendar */\n\tfeedUrl: \"calendar.feedUrl\",\n\t/** Path of the written `.ics` file */\n\tfeedFile: \"calendar.feedFile\",\n\t/** Instant the calendar was written */\n\tupdatedAt: \"calendar.updatedAt\",\n\t/** The absences as JSON */\n\tabsences: \"calendar.absences\",\n} as const;\n\n/** What the calendar states show. */\nexport interface CalendarSnapshot {\n\t/** Subscription link, empty while no token exists */\n\tfeedUrl: string;\n\t/** Absolute path of the written `.ics` file */\n\tfeedFile: string;\n\t/** Instant the file and the JSON were written */\n\tupdatedAt: number;\n\t/** The absences as JSON text */\n\tabsences: string;\n}\n\n/**\n * Creates the states of the calendar.\n *\n * @param port - state port\n */\nexport async function createCalendarStates(port: StatePort): Promise<void> {\n\tawait ensureObject(port, \"calendar\", channelObject(STATE_NAMES.calendarChannel));\n\tawait ensureObject(port, CALENDAR_IDS.feedUrl, stateObject(STATE_NAMES.calendarFeedUrl, \"string\", \"text\"));\n\tawait ensureObject(port, CALENDAR_IDS.feedFile, stateObject(STATE_NAMES.calendarFeedFile, \"string\", \"text\"));\n\tawait ensureObject(\n\t\tport,\n\t\tCALENDAR_IDS.updatedAt,\n\t\tstateObject(STATE_NAMES.calendarUpdatedAt, \"number\", \"value.time\"),\n\t);\n\tawait ensureObject(port, CALENDAR_IDS.absences, stateObject(STATE_NAMES.calendarAbsences, \"string\", \"text\"));\n}\n\n/**\n * Publishes the calendar of the instance.\n *\n * @param port - state port\n * @param snapshot - data to publish\n */\nexport async function publishCalendarSnapshot(port: StatePort, snapshot: CalendarSnapshot): Promise<void> {\n\tawait port.setState(CALENDAR_IDS.feedUrl, snapshot.feedUrl, true);\n\tawait port.setState(CALENDAR_IDS.feedFile, snapshot.feedFile, true);\n\tawait port.setState(CALENDAR_IDS.updatedAt, snapshot.updatedAt, true);\n\tawait port.setState(CALENDAR_IDS.absences, snapshot.absences, true);\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,wBAA4C;AAC5C,kBAA8C;AA+EvC,MAAM,cAAc;AAAA;AAAA,EAE1B,aAAa;AAAA;AAAA,EAEb,OAAO;AAAA;AAAA,EAEP,YAAY;AAAA;AAAA,EAEZ,YAAY;AAAA;AAAA,EAEZ,QAAQ;AAAA;AAAA,EAER,QAAQ;AAAA;AAAA,EAER,qBAAqB;AACtB;AAgBA,SAAS,YACR,MACA,MACA,MACA,UAA8D,CAAC,GACrC;AAC1B,SAAO;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM,QAAQ,SAAS;AAAA,MACvB,OAAO,QAAQ,UAAU;AAAA,MACzB,GAAI,QAAQ,OAAO,EAAE,MAAM,QAAQ,KAAK,IAAI,CAAC;AAAA,IAC9C;AAAA,IACA,QAAQ,CAAC;AAAA,EACV;AACD;AAUA,SAAS,cAAc,MAA0C;AAChE,SAAO,EAAE,MAAM,WAAW,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC,EAAE;AACxD;AAcA,eAAe,aAAa,MAAiB,IAAY,QAAgD;AA9KzG;AA+KC,QAAM,KAAK,mBAAmB,IAAI,MAAM;AACxC,QAAM,SAAkC,EAAE,IAAI,YAAO,WAAP,YAAiB,CAAC,EAAG;AACnE,SAAO,OAAO;AACd,QAAM,KAAK,aAAa,IAAI,EAAE,OAAO,CAAC;AACvC;AAaO,SAAS,iBAAiB,MAMT;AAtMxB;AAuMC,QAAM,OAAO,KAAK,MAAM,SAAS,KAAK,MAAM;AAC5C,MAAI,CAAC,MAAM;AACV,WAAO;AAAA,EACR;AAEA,QAAM,aAAY,UAAK,QAAL,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAC1D,QAAM,YAAQ,YAAAA,WAAiB,WAAW,KAAK,QAAQ;AACvD,QAAM,MAAM,KAAK,YAAY,eAAe,KAAK,IAAI,OAAO,EAAE,KAAK,UAAU,CAAC;AAE9E,QAAM,OAAO,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC;AACrC,QAAM,QAAQ,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC;AACtC,QAAM,iBAAiB,KAAK,YAAY,iBAAiB,KAAK,IAAI,MAAM,OAAO,EAAE,KAAK,UAAU,CAAC;AACjG,QAAM,gBAAgB,KAAK,YAAY,KAAK,KAAK,IAAI,IAAI;AAEzD,SAAO;AAAA,IACN,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,IAAI;AAAA,IAClB,cAAc,KAAK,KAAI,SAAI,eAAJ,YAAkB,IAAG,SAAI,eAAJ,YAAkB,CAAC,KAAK;AAAA,IACpE,eAAe,IAAI;AAAA,IACnB,gBAAgB,IAAI;AAAA,IACpB,eAAe,KAAK,KAAK,UAAU,KAAK,EAAE,EAAE;AAAA,IAC5C,oBAAoB,eAAe;AAAA,IACnC,qBAAqB,eAAe;AAAA,IACpC,oBAAoB,gBAAgB,cAAc,YAAY,cAAc,YAAY;AAAA,EACzF;AACD;AAQA,eAAsB,oBAAoB,MAAiB,UAAuC;AAzOlG;AA0OC,QAAM,KAAK,SAAS,SAAS,MAAM;AACnC,QAAM,KAAK,SAAS,GAAG,EAAE,gBAAgB,SAAS,aAAa,IAAI;AACnE,QAAM,KAAK,SAAS,GAAG,EAAE,YAAY,SAAS,cAAc,IAAI;AAChE,QAAM,KAAK,SAAS,GAAG,EAAE,iBAAiB,SAAS,cAAc,IAAI;AACrE,QAAM,KAAK,SAAS,GAAG,EAAE,eAAc,cAAS,iBAAT,YAAyB,GAAG,IAAI;AACvE,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,eAAe,IAAI;AAC5E,QAAM,KAAK,SAAS,GAAG,EAAE,wBAAwB,SAAS,gBAAgB,IAAI;AAC9E,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,oBAAoB,IAAI;AACjF,QAAM,KAAK,SAAS,GAAG,EAAE,wBAAwB,SAAS,qBAAqB,IAAI;AACnF,QAAM,KAAK,SAAS,GAAG,EAAE,uBAAuB,SAAS,oBAAoB,IAAI;AACjF,QAAM,KAAK,SAAS,GAAG,EAAE,kBAAkB,SAAS,eAAe,IAAI;AACxE;AAaA,eAAsB,qBAAqB,MAMf;AAC3B,QAAM,YAA4B,CAAC;AACnC,aAAW,QAAQ,KAAK,MAAM,KAAK,EAAE,iBAAiB,KAAK,CAAC,GAAG;AAC9D,UAAM,kBAAkB,KAAK,MAAM,KAAK,EAAE;AAC1C,UAAM,WAAW,iBAAiB;AAAA,MACjC,aAAa,KAAK;AAAA,MAClB,OAAO,KAAK;AAAA,MACZ,MAAM,KAAK;AAAA,MACX,QAAQ,KAAK;AAAA,MACb,KAAK,KAAK;AAAA,IACX,CAAC;AACD,QAAI,CAAC,UAAU;AACd;AAAA,IACD;AACA,UAAM,oBAAoB,KAAK,MAAM,QAAQ;AAC7C,cAAU,KAAK,QAAQ;AAAA,EACxB;AACA,SAAO;AACR;AAaA,eAAsB,kBAAkB,MAAiB,QAAiC;AACzF,QAAM,KAAK,SAAS,MAAM;AAC1B,QAAM,aAAa,MAAM,SAAS,cAAc,8BAAY,YAAY,CAAC;AACzE,QAAM,aAAa,MAAM,IAAI,cAAc,8BAAY,eAAe,CAAC;AACvE,QAAM,aAAa,MAAM,GAAG,EAAE,gBAAgB,YAAY,8BAAY,aAAa,UAAU,WAAW,CAAC;AAGzG,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,aAAa,WAAW,UAAU,EAAE,OAAO,KAAK,CAAC;AAAA,EAC1E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,cAAc,WAAW,mBAAmB;AAAA,EACrE;AACA,QAAM,aAAa,MAAM,GAAG,EAAE,cAAc,YAAY,8BAAY,WAAW,UAAU,YAAY,CAAC;AACtG,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,qBAAqB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAChF;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,qBAAqB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAChF;AACA,QAAM;AAAA,IACL;AAAA,IACA,GAAG,EAAE;AAAA,IACL,YAAY,8BAAY,oBAAoB,UAAU,SAAS,EAAE,MAAM,MAAM,CAAC;AAAA,EAC/E;AACA,QAAM,aAAa,MAAM,GAAG,EAAE,kBAAkB,YAAY,8BAAY,eAAe,UAAU,OAAO,CAAC;AACzG,SAAO;AACR;AAOA,eAAsB,oBAAoB,MAAgC;AACzE,QAAM,aAAa,MAAM,YAAY,cAAc,8BAAY,eAAe,CAAC;AAC/E,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,aAAa,UAAU,SAAS;AAAA,MACvD,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,OAAO,WAAW,UAAU;AAAA,MACnD,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,YAAY,WAAW,UAAU;AAAA,MACxD,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,YAAY,UAAU,QAAQ;AAAA,MACrD,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM,aAAa,MAAM,YAAY,QAAQ,YAAY,8BAAY,QAAQ,UAAU,QAAQ,EAAE,OAAO,KAAK,CAAC,CAAC;AAC/G,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,eAAe,WAAW,UAAU;AAAA,MAC3D,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACA,QAAM;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,YAAY,8BAAY,qBAAqB,WAAW,UAAU;AAAA,MACjE,MAAM;AAAA,MACN,OAAO;AAAA,IACR,CAAC;AAAA,EACF;AACD;AAOA,eAAsB,iBAAiB,MAAgC;AACtE,QAAM,aAAa,MAAM,QAAQ,cAAc,8BAAY,WAAW,CAAC;AACvE,QAAM,aAAa,MAAM,gBAAgB,YAAY,8BAAY,SAAS,UAAU,MAAM,CAAC;AAC3F,QAAM,aAAa,MAAM,sBAAsB,YAAY,8BAAY,eAAe,UAAU,MAAM,CAAC;AACvG,QAAM;AAAA,IACL;AAAA,IACA;AAAA,IACA,YAAY,8BAAY,aAAa,UAAU,SAAS,EAAE,MAAM,QAAQ,CAAC;AAAA,EAC1E;AACA,QAAM,aAAa,MAAM,kBAAkB,YAAY,8BAAY,WAAW,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,YAAY,UAAU,YAAY,CAAC;AACxG;AAOA,eAAsB,oBAAoB,MAAgC;AACzE,QAAM,aAAa,MAAM,WAAW,cAAc,8BAAY,cAAc,CAAC;AAC7E,QAAM,aAAa,MAAM,wBAAwB,YAAY,8BAAY,qBAAqB,UAAU,OAAO,CAAC;AAChH,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,gBAAgB,UAAU,MAAM,CAAC;AACrG,QAAM,aAAa,MAAM,yBAAyB,YAAY,8BAAY,eAAe,UAAU,OAAO,CAAC;AAC3G,QAAM,aAAa,MAAM,qBAAqB,YAAY,8BAAY,kBAAkB,UAAU,YAAY,CAAC;AAChH;AAQO,SAAS,oBAAoB,WAA4C;AAC/E,QAAM,UAAU,UAAU,OAAO,cAAY,SAAS,YAAY;AAClE,SAAO;AAAA,IACN,cAAc,QAAQ;AAAA,IACtB,SAAS,QAAQ,IAAI,cAAY,SAAS,WAAW,EAAE,KAAK,IAAI;AAAA,IAChE,eAAe,UAAU,OAAO,CAAC,KAAK,aAAa,MAAM,SAAS,eAAe,CAAC;AAAA,IAClF,cAAc,UAAU;AAAA,MACvB,CAAC,QAAQ,aACR,SAAS,iBAAiB,SAAS,WAAW,QAAQ,SAAS,eAAe,UAC3E,SAAS,eACT;AAAA,MACJ;AAAA,IACD;AAAA,EACD;AACD;AAQA,eAAsB,uBAAuB,MAAiB,UAA0C;AAzcxG;AA0cC,QAAM,KAAK,SAAS,wBAAwB,SAAS,cAAc,IAAI;AACvE,QAAM,KAAK,SAAS,mBAAmB,SAAS,SAAS,IAAI;AAC7D,QAAM,KAAK,SAAS,yBAAyB,SAAS,eAAe,IAAI;AACzE,QAAM,KAAK,SAAS,sBAAqB,cAAS,iBAAT,YAAyB,GAAG,IAAI;AAC1E;AAOA,eAAsB,kBAAkB,MAAgC;AACvE,QAAM,aAAa,MAAM,UAAU,cAAc,8BAAY,aAAa,CAAC;AAC3E,QAAM,aAAa,MAAM,iBAAiB,YAAY,8BAAY,QAAQ,UAAU,YAAY,CAAC;AACjG,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,UAAU,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,mBAAmB,YAAY,8BAAY,UAAU,UAAU,MAAM,CAAC;AAC/F,QAAM,aAAa,MAAM,wBAAwB,YAAY,8BAAY,eAAe,UAAU,MAAM,CAAC;AACzG,QAAM,aAAa,MAAM,qBAAqB,YAAY,8BAAY,YAAY,UAAU,MAAM,CAAC;AACpG;AAQA,eAAsB,qBAAqB,MAAiB,UAAwC;AACnG,QAAM,KAAK,SAAS,iBAAiB,SAAS,OAAO,IAAI;AACzD,QAAM,KAAK,SAAS,mBAAmB,SAAS,MAAM,IAAI;AAC1D,QAAM,KAAK,SAAS,mBAAmB,SAAS,UAAU,IAAI;AAC9D,QAAM,KAAK,SAAS,wBAAwB,SAAS,WAAW,IAAI;AACpE,QAAM,KAAK,SAAS,qBAAqB,SAAS,QAAQ,IAAI;AAC/D;AAGO,MAAM,eAAe;AAAA;AAAA,EAE3B,SAAS;AAAA;AAAA,EAET,UAAU;AAAA;AAAA,EAEV,WAAW;AAAA;AAAA,EAEX,UAAU;AACX;AAmBA,eAAsB,qBAAqB,MAAgC;AAC1E,QAAM,aAAa,MAAM,YAAY,cAAc,8BAAY,eAAe,CAAC;AAC/E,QAAM,aAAa,MAAM,aAAa,SAAS,YAAY,8BAAY,iBAAiB,UAAU,MAAM,CAAC;AACzG,QAAM,aAAa,MAAM,aAAa,UAAU,YAAY,8BAAY,kBAAkB,UAAU,MAAM,CAAC;AAC3G,QAAM;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,YAAY,8BAAY,mBAAmB,UAAU,YAAY;AAAA,EAClE;AACA,QAAM,aAAa,MAAM,aAAa,UAAU,YAAY,8BAAY,kBAAkB,UAAU,MAAM,CAAC;AAC5G;AAQA,eAAsB,wBAAwB,MAAiB,UAA2C;AACzG,QAAM,KAAK,SAAS,aAAa,SAAS,SAAS,SAAS,IAAI;AAChE,QAAM,KAAK,SAAS,aAAa,UAAU,SAAS,UAAU,IAAI;AAClE,QAAM,KAAK,SAAS,aAAa,WAAW,SAAS,WAAW,IAAI;AACpE,QAAM,KAAK,SAAS,aAAa,UAAU,SAAS,UAAU,IAAI;AACnE;",
|
|
6
6
|
"names": ["resolveLocalDate"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var calendar_exports = {};
|
|
20
|
+
__export(calendar_exports, {
|
|
21
|
+
CALENDAR_TTL: () => CALENDAR_TTL,
|
|
22
|
+
COMPANY_CALENDAR_NAME: () => COMPANY_CALENDAR_NAME,
|
|
23
|
+
ICAL_CONTENT_TYPE: () => ICAL_CONTENT_TYPE,
|
|
24
|
+
absenceEvents: () => absenceEvents,
|
|
25
|
+
absenceFeed: () => absenceFeed,
|
|
26
|
+
calendarDocument: () => calendarDocument,
|
|
27
|
+
dayAfter: () => dayAfter,
|
|
28
|
+
shiftDate: () => shiftDate
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(calendar_exports);
|
|
31
|
+
const ICAL_CONTENT_TYPE = "text/calendar; charset=utf-8";
|
|
32
|
+
const CALENDAR_TTL = "PT2H";
|
|
33
|
+
const COMPANY_CALENDAR_NAME = "Abwesenheiten (Firma)";
|
|
34
|
+
function icalText(value) {
|
|
35
|
+
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
|
36
|
+
}
|
|
37
|
+
function icalStamp(seconds) {
|
|
38
|
+
return new Date(seconds * 1e3).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
39
|
+
}
|
|
40
|
+
function dayAfter(date) {
|
|
41
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
42
|
+
return new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
function shiftDate(date, days) {
|
|
45
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
46
|
+
return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
function absenceEvents(absences, employees, withEmployee) {
|
|
49
|
+
return absences.map((absence) => {
|
|
50
|
+
const portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : "";
|
|
51
|
+
const employee = employees.get(absence.userId);
|
|
52
|
+
const own = `${absence.typeName} (${absence.typeCode})${portion}`;
|
|
53
|
+
return {
|
|
54
|
+
uid: `absence-${absence.id}@time-tracker`,
|
|
55
|
+
start: absence.dateFrom,
|
|
56
|
+
end: absence.dateTo,
|
|
57
|
+
summary: withEmployee && employee ? `${employee.displayName}: ${own}` : own,
|
|
58
|
+
confirmed: absence.approval === "approved",
|
|
59
|
+
description: absence.note
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function calendarDocument(name, events, stamp) {
|
|
64
|
+
const lines = [
|
|
65
|
+
"BEGIN:VCALENDAR",
|
|
66
|
+
"VERSION:2.0",
|
|
67
|
+
"PRODID:-//ioBroker//time-tracker//EN",
|
|
68
|
+
"CALSCALE:GREGORIAN",
|
|
69
|
+
"METHOD:PUBLISH",
|
|
70
|
+
`X-WR-CALNAME:${icalText(name)}`,
|
|
71
|
+
`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,
|
|
72
|
+
`X-PUBLISHED-TTL:${CALENDAR_TTL}`
|
|
73
|
+
];
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
lines.push(
|
|
76
|
+
"BEGIN:VEVENT",
|
|
77
|
+
`UID:${event.uid}`,
|
|
78
|
+
`DTSTAMP:${icalStamp(stamp)}`,
|
|
79
|
+
`DTSTART;VALUE=DATE:${event.start.replace(/-/g, "")}`,
|
|
80
|
+
`DTEND;VALUE=DATE:${dayAfter(event.end).replace(/-/g, "")}`,
|
|
81
|
+
`SUMMARY:${icalText(event.summary)}`,
|
|
82
|
+
event.confirmed ? "STATUS:CONFIRMED" : "STATUS:TENTATIVE",
|
|
83
|
+
event.description ? `DESCRIPTION:${icalText(event.description)}` : null,
|
|
84
|
+
"TRANSP:TRANSPARENT",
|
|
85
|
+
"END:VEVENT"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
lines.push("END:VCALENDAR");
|
|
89
|
+
return `${lines.filter((line) => line !== null).join("\r\n")}\r
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
function absenceFeed(absences, employees) {
|
|
93
|
+
return absences.map((absence) => {
|
|
94
|
+
var _a, _b;
|
|
95
|
+
const employee = employees.get(absence.userId);
|
|
96
|
+
return {
|
|
97
|
+
id: absence.id,
|
|
98
|
+
userId: absence.userId,
|
|
99
|
+
login: (_a = employee == null ? void 0 : employee.login) != null ? _a : "",
|
|
100
|
+
name: (_b = employee == null ? void 0 : employee.displayName) != null ? _b : "",
|
|
101
|
+
from: absence.dateFrom,
|
|
102
|
+
to: absence.dateTo,
|
|
103
|
+
code: absence.typeCode,
|
|
104
|
+
type: absence.typeName,
|
|
105
|
+
portion: absence.dayPortion,
|
|
106
|
+
approval: absence.approval,
|
|
107
|
+
status: absence.status,
|
|
108
|
+
note: absence.note
|
|
109
|
+
};
|
|
110
|
+
}).sort(
|
|
111
|
+
(left, right) => left.from.localeCompare(right.from) || left.name.localeCompare(right.name) || left.code.localeCompare(right.code)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
115
|
+
0 && (module.exports = {
|
|
116
|
+
CALENDAR_TTL,
|
|
117
|
+
COMPANY_CALENDAR_NAME,
|
|
118
|
+
ICAL_CONTENT_TYPE,
|
|
119
|
+
absenceEvents,
|
|
120
|
+
absenceFeed,
|
|
121
|
+
calendarDocument,
|
|
122
|
+
dayAfter,
|
|
123
|
+
shiftDate
|
|
124
|
+
});
|
|
125
|
+
//# sourceMappingURL=calendar.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/services/calendar.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * The calendar of the time tracking: absences as iCalendar and as plain data.\n *\n * Two consumers share this module: the **calendar feed** (`GET /calendar.ics`) hands a subscription link to a\n * calendar app \u2014 for one employee or, with the token of the instance, for the whole company \u2014 and the **adapter**\n * publishes the same data to ioBroker (an `.ics` file for the `ical` adapter, `calendar.absences` as JSON for\n * scripts and dashboards).\n *\n * The document is real iCalendar: an all-day event ends **exclusively** (`DTEND` is the day after the last day,\n * otherwise every calendar drops that day), a day that still waits for its decision is `TENTATIVE`, and the `UID` of\n * an absence is derived from its row id \u2014 that is what lets a calendar app update an event instead of adding it\n * again on every refresh.\n */\n\nimport type { AbsenceWithType } from \"../db/repositories/absences\";\n\n/** Media type of a calendar document. */\nexport const ICAL_CONTENT_TYPE = \"text/calendar; charset=utf-8\";\n\n/** How long a calendar app may cache a feed (a hint \u2014 Google caches longer anyway). */\nexport const CALENDAR_TTL = \"PT2H\";\n\n/** Name of the calendar that carries every employee (the feed of the instance and the written file). */\nexport const COMPANY_CALENDAR_NAME = \"Abwesenheiten (Firma)\";\n\n/** The few fields of an employee a calendar needs. */\nexport interface CalendarEmployee {\n\t/** Database id of the employee */\n\tid: number;\n\t/** Shown name */\n\tdisplayName: string;\n\t/** Login, used by the JSON view */\n\tlogin: string;\n}\n\n/** One event of the calendar, independent of how it is rendered. */\nexport interface CalendarEvent {\n\t/** Stable id, so a calendar app updates an event instead of adding it again */\n\tuid: string;\n\t/** First day, `YYYY-MM-DD` */\n\tstart: string;\n\t/** Last day, `YYYY-MM-DD` (included in the event) */\n\tend: string;\n\t/** Title shown in the calendar */\n\tsummary: string;\n\t/** True when the absence is approved and the day is taken */\n\tconfirmed: boolean;\n\t/** Note of the absence, `null` when there is none */\n\tdescription: string | null;\n}\n\n/** One absence in the plain data view (`calendar.absences`). */\nexport interface AbsenceFeedRow {\n\t/** Database id of the absence */\n\tid: number;\n\t/** Database id of the employee */\n\tuserId: number;\n\t/** Login of the employee */\n\tlogin: string;\n\t/** Shown name of the employee */\n\tname: string;\n\t/** First day, `YYYY-MM-DD` */\n\tfrom: string;\n\t/** Last day, `YYYY-MM-DD` */\n\tto: string;\n\t/** Code of the absence type, e.g. `U` */\n\tcode: string;\n\t/** Name of the absence type, e.g. `Urlaub` */\n\ttype: string;\n\t/** How much of a day the absence covers (`1` for a whole day) */\n\tportion: number;\n\t/** `approved`, `requested` or `rejected` */\n\tapproval: string;\n\t/** `planned` for a day in the future, `taken` for the past */\n\tstatus: string;\n\t/** Note, `null` when there is none */\n\tnote: string | null;\n}\n\n/**\n * Escapes a text for an iCalendar value.\n *\n * @param value - raw text\n * @returns the text with the characters iCalendar reserves\n */\nfunction icalText(value: string): string {\n\treturn value.replace(/\\\\/g, \"\\\\\\\\\").replace(/;/g, \"\\\\;\").replace(/,/g, \"\\\\,\").replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/**\n * Formats an instant as a UTC stamp (`YYYYMMDDTHHMMSSZ`).\n *\n * @param seconds - UTC epoch seconds\n * @returns the iCalendar timestamp\n */\nfunction icalStamp(seconds: number): string {\n\treturn new Date(seconds * 1000)\n\t\t.toISOString()\n\t\t.replace(/[-:]/g, \"\")\n\t\t.replace(/\\.\\d{3}/, \"\");\n}\n\n/**\n * The day after a local date.\n *\n * iCalendar ends an all-day event **exclusively**: a vacation from the 5th to the 9th has to carry `DTEND` as the\n * 10th, otherwise every calendar shows it one day short.\n *\n * @param date - local date `YYYY-MM-DD`\n * @returns the following day\n */\nexport function dayAfter(date: string): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);\n}\n\n/**\n * Shifts a local date by whole days.\n *\n * @param date - local date `YYYY-MM-DD`\n * @param days - days to add (negative values go back)\n * @returns the shifted date\n */\nexport function shiftDate(date: string, days: number): string {\n\tconst [year, month, day] = date.split(\"-\").map(Number);\n\treturn new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);\n}\n\n/**\n * Turns absences into calendar events.\n *\n * A half day cannot be an all-day event, so its portion travels in the summary. `withEmployee` puts the name of the\n * employee in front \u2014 that is what the calendar of the whole company needs, while the own calendar does not repeat\n * it.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @param withEmployee - true to name the employee in the summary\n * @returns the events\n */\nexport function absenceEvents(\n\tabsences: AbsenceWithType[],\n\temployees: Map<number, CalendarEmployee>,\n\twithEmployee: boolean,\n): CalendarEvent[] {\n\treturn absences.map(absence => {\n\t\tconst portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : \"\";\n\t\tconst employee = employees.get(absence.userId);\n\t\tconst own = `${absence.typeName} (${absence.typeCode})${portion}`;\n\t\treturn {\n\t\t\tuid: `absence-${absence.id}@time-tracker`,\n\t\t\tstart: absence.dateFrom,\n\t\t\tend: absence.dateTo,\n\t\t\tsummary: withEmployee && employee ? `${employee.displayName}: ${own}` : own,\n\t\t\tconfirmed: absence.approval === \"approved\",\n\t\t\tdescription: absence.note,\n\t\t};\n\t});\n}\n/**\n * Builds a `VCALENDAR` document.\n *\n * @param name - name of the calendar (the employee, or the company)\n * @param events - events to write\n * @param stamp - instant of the answer (`DTSTAMP`)\n * @returns the iCalendar document\n */\nexport function calendarDocument(name: string, events: CalendarEvent[], stamp: number): string {\n\tconst lines: (string | null)[] = [\n\t\t\"BEGIN:VCALENDAR\",\n\t\t\"VERSION:2.0\",\n\t\t\"PRODID:-//ioBroker//time-tracker//EN\",\n\t\t\"CALSCALE:GREGORIAN\",\n\t\t\"METHOD:PUBLISH\",\n\t\t`X-WR-CALNAME:${icalText(name)}`,\n\t\t`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,\n\t\t`X-PUBLISHED-TTL:${CALENDAR_TTL}`,\n\t];\n\tfor (const event of events) {\n\t\tlines.push(\n\t\t\t\"BEGIN:VEVENT\",\n\t\t\t`UID:${event.uid}`,\n\t\t\t`DTSTAMP:${icalStamp(stamp)}`,\n\t\t\t`DTSTART;VALUE=DATE:${event.start.replace(/-/g, \"\")}`,\n\t\t\t`DTEND;VALUE=DATE:${dayAfter(event.end).replace(/-/g, \"\")}`,\n\t\t\t`SUMMARY:${icalText(event.summary)}`,\n\t\t\tevent.confirmed ? \"STATUS:CONFIRMED\" : \"STATUS:TENTATIVE\",\n\t\t\tevent.description ? `DESCRIPTION:${icalText(event.description)}` : null,\n\t\t\t\"TRANSP:TRANSPARENT\",\n\t\t\t\"END:VEVENT\",\n\t\t);\n\t}\n\tlines.push(\"END:VCALENDAR\");\n\treturn `${lines.filter(line => line !== null).join(\"\\r\\n\")}\\r\\n`;\n}\n\n/**\n * The plain data view of the absences, for scripts and dashboards.\n *\n * The list is sorted by day, employee name and type, so it reads like the calendar does.\n *\n * @param absences - absences of the period\n * @param employees - the employees the absences belong to\n * @returns the rows\n */\nexport function absenceFeed(absences: AbsenceWithType[], employees: Map<number, CalendarEmployee>): AbsenceFeedRow[] {\n\treturn absences\n\t\t.map(absence => {\n\t\t\tconst employee = employees.get(absence.userId);\n\t\t\treturn {\n\t\t\t\tid: absence.id,\n\t\t\t\tuserId: absence.userId,\n\t\t\t\tlogin: employee?.login ?? \"\",\n\t\t\t\tname: employee?.displayName ?? \"\",\n\t\t\t\tfrom: absence.dateFrom,\n\t\t\t\tto: absence.dateTo,\n\t\t\t\tcode: absence.typeCode,\n\t\t\t\ttype: absence.typeName,\n\t\t\t\tportion: absence.dayPortion,\n\t\t\t\tapproval: absence.approval,\n\t\t\t\tstatus: absence.status,\n\t\t\t\tnote: absence.note,\n\t\t\t};\n\t\t})\n\t\t.sort(\n\t\t\t(left, right) =>\n\t\t\t\tleft.from.localeCompare(right.from) ||\n\t\t\t\tleft.name.localeCompare(right.name) ||\n\t\t\t\tleft.code.localeCompare(right.code),\n\t\t);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -38,6 +38,7 @@ var import_absences = require("../db/repositories/absences");
|
|
|
38
38
|
var import_audit = require("../db/repositories/audit");
|
|
39
39
|
var import_rfid = require("../db/repositories/rfid");
|
|
40
40
|
var import_auth = require("../services/auth");
|
|
41
|
+
var import_calendar = require("../services/calendar");
|
|
41
42
|
var import_errors = require("../errors");
|
|
42
43
|
var import_seed = require("../db/seed");
|
|
43
44
|
var import_punch = require("../domain/punch");
|
|
@@ -377,23 +378,6 @@ function createApi(deps) {
|
|
|
377
378
|
const emit = (event) => {
|
|
378
379
|
events.publish({ ...event, atUtc: now() });
|
|
379
380
|
};
|
|
380
|
-
function requireInsideEditWindow(context, ownerId, tsUtc) {
|
|
381
|
-
const actor = context.auth;
|
|
382
|
-
if (!actor || actor.user.id !== ownerId || actor.permissions.includes("time.edit_other")) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
const days = settings.getNumber("edit_window_days", 0);
|
|
386
|
-
if (days <= 0) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
if (tsUtc < now() - days * 86400) {
|
|
390
|
-
throw (0, import_problem.problem)(
|
|
391
|
-
403,
|
|
392
|
-
"edit_window_closed",
|
|
393
|
-
`own punches may only be changed within ${days} day(s) (edit_window_days)`
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
381
|
function requireActiveAdministrator(userId, next) {
|
|
398
382
|
if (next.isActive && next.roleKeys.includes("admin")) {
|
|
399
383
|
return;
|
|
@@ -784,7 +768,6 @@ function createApi(deps) {
|
|
|
784
768
|
if (tsUtc === null) {
|
|
785
769
|
throw new import_errors.ValidationError("tsUtc is required");
|
|
786
770
|
}
|
|
787
|
-
requireInsideEditWindow(context, target, tsUtc);
|
|
788
771
|
const timestamp = now();
|
|
789
772
|
const stored = entries.insert({
|
|
790
773
|
userId: target,
|
|
@@ -818,44 +801,11 @@ function createApi(deps) {
|
|
|
818
801
|
{ location: `/entries/${stored.entry.id}` }
|
|
819
802
|
);
|
|
820
803
|
});
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const [year, month, day] = date.split("-").map(Number);
|
|
827
|
-
return new Date(Date.UTC(year, month - 1, day + 1)).toISOString().slice(0, 10);
|
|
828
|
-
};
|
|
829
|
-
const calendarDocument = (user, list, stamp) => {
|
|
830
|
-
const lines = [
|
|
831
|
-
"BEGIN:VCALENDAR",
|
|
832
|
-
"VERSION:2.0",
|
|
833
|
-
"PRODID:-//ioBroker//time-tracker//EN",
|
|
834
|
-
"CALSCALE:GREGORIAN",
|
|
835
|
-
"METHOD:PUBLISH",
|
|
836
|
-
`X-WR-CALNAME:${icalText(`Abwesenheiten ${user.displayName}`)}`,
|
|
837
|
-
`REFRESH-INTERVAL;VALUE=DURATION:${CALENDAR_TTL}`,
|
|
838
|
-
`X-PUBLISHED-TTL:${CALENDAR_TTL}`
|
|
839
|
-
];
|
|
840
|
-
for (const absence of list) {
|
|
841
|
-
const portion = absence.dayPortion < 1 ? ` (${absence.dayPortion})` : "";
|
|
842
|
-
lines.push(
|
|
843
|
-
"BEGIN:VEVENT",
|
|
844
|
-
`UID:absence-${absence.id}@time-tracker`,
|
|
845
|
-
`DTSTAMP:${icalStamp(stamp)}`,
|
|
846
|
-
`DTSTART;VALUE=DATE:${absence.dateFrom.replace(/-/g, "")}`,
|
|
847
|
-
`DTEND;VALUE=DATE:${dayAfter(absence.dateTo).replace(/-/g, "")}`,
|
|
848
|
-
`SUMMARY:${icalText(`${absence.typeName} (${absence.typeCode})${portion}`)}`,
|
|
849
|
-
absence.approval === "approved" ? "STATUS:CONFIRMED" : "STATUS:TENTATIVE",
|
|
850
|
-
absence.note ? `DESCRIPTION:${icalText(absence.note)}` : null,
|
|
851
|
-
"TRANSP:TRANSPARENT",
|
|
852
|
-
"END:VEVENT"
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
lines.push("END:VCALENDAR");
|
|
856
|
-
return `${lines.filter((line) => line !== null).join("\r\n")}\r
|
|
857
|
-
`;
|
|
858
|
-
};
|
|
804
|
+
const calendarEmployee = (user) => ({
|
|
805
|
+
id: user.id,
|
|
806
|
+
displayName: user.displayName,
|
|
807
|
+
login: user.login
|
|
808
|
+
});
|
|
859
809
|
const publicAbsence = (absence) => {
|
|
860
810
|
var _a2, _b2;
|
|
861
811
|
const type = absences.findType(absence.typeId);
|
|
@@ -1065,18 +1015,29 @@ function createApi(deps) {
|
|
|
1065
1015
|
(context) => {
|
|
1066
1016
|
var _a2;
|
|
1067
1017
|
const token = ((_a2 = context.query("token")) != null ? _a2 : "").trim();
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1018
|
+
const instanceToken = settings.get("calendar_token");
|
|
1019
|
+
const company = instanceToken !== null && instanceToken !== "" && token === instanceToken;
|
|
1020
|
+
const user = company || token === "" ? null : users.findByCalendarToken(token);
|
|
1021
|
+
if (!company && (!user || !user.isActive)) {
|
|
1070
1022
|
throw (0, import_problem.problem)(401, "invalid_credentials", "the calendar token is not valid");
|
|
1071
1023
|
}
|
|
1072
1024
|
const stamp = now();
|
|
1073
|
-
const
|
|
1074
|
-
const
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
}
|
|
1025
|
+
const year = new Date(stamp * 1e3).getUTCFullYear();
|
|
1026
|
+
const from = `${year - 1}-01-01`;
|
|
1027
|
+
const to = `${year + 1}-12-31`;
|
|
1028
|
+
const owners = user ? [user] : users.list();
|
|
1029
|
+
const employees = new Map(owners.map((employee) => [employee.id, calendarEmployee(employee)]));
|
|
1030
|
+
const list = user ? absences.withTypesInRange(user.id, from, to) : absences.allInRange(from, to);
|
|
1031
|
+
const name = user ? `Abwesenheiten ${user.displayName}` : import_calendar.COMPANY_CALENDAR_NAME;
|
|
1032
|
+
return (0, import_router.binary)(
|
|
1033
|
+
200,
|
|
1034
|
+
Buffer.from((0, import_calendar.calendarDocument)(name, (0, import_calendar.absenceEvents)(list, employees, !user), stamp), "utf8"),
|
|
1035
|
+
import_calendar.ICAL_CONTENT_TYPE,
|
|
1036
|
+
{
|
|
1037
|
+
"content-disposition": 'inline; filename="time-tracker.ics"',
|
|
1038
|
+
"cache-control": "no-store"
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1080
1041
|
}
|
|
1081
1042
|
);
|
|
1082
1043
|
route("POST", "/calendar/token", { permission: "report.view_own", csrf: true }, (context) => {
|
|
@@ -1510,10 +1471,6 @@ function createApi(deps) {
|
|
|
1510
1471
|
if (wantedTs !== null && !context.auth.permissions.includes("time.edit_other")) {
|
|
1511
1472
|
throw (0, import_problem.problem)(403, "permission_denied", "request rejected (permission_denied: time.edit_other)");
|
|
1512
1473
|
}
|
|
1513
|
-
if (wantedTs !== null) {
|
|
1514
|
-
requireInsideEditWindow(context, existing.userId, existing.tsUtc);
|
|
1515
|
-
requireInsideEditWindow(context, existing.userId, wantedTs);
|
|
1516
|
-
}
|
|
1517
1474
|
const target = users.findById(existing.userId);
|
|
1518
1475
|
if (!target) {
|
|
1519
1476
|
throw new import_errors.NotFoundError(`user ${existing.userId} not found`);
|